Compare commits

...

59 Commits

Author SHA1 Message Date
post-deploy-monitor
e2c4881b21 docs(ORCH-021): post-deploy HEALTHY/NONE for ORCH-026
All checks were successful
CI / test (push) Successful in 25s
2026-06-08 20:03:03 +03:00
deploy-finalizer
662d2d6434 deploy(ORCH-036): finalize SUCCESS for ORCH-026
All checks were successful
CI / test (push) Successful in 26s
2026-06-08 19:47:57 +03:00
deploy-finalizer
90a5cae8e6 deploy(ORCH-036): finalize FAILED for ORCH-026
All checks were successful
CI / test (push) Successful in 23s
CI / test (pull_request) Successful in 23s
2026-06-08 19:33:57 +03:00
deploy-finalizer
1d928dab57 deploy(ORCH-036): finalize FAILED for ORCH-026
All checks were successful
CI / test (push) Successful in 23s
CI / test (pull_request) Successful in 22s
2026-06-08 19:28:41 +03:00
9800dc89e3 tester(ET): auto-commit from tester run_id=390
All checks were successful
CI / test (push) Successful in 27s
CI / test (pull_request) Successful in 27s
2026-06-08 19:17:44 +03:00
5b80f8facb reviewer(ET): auto-commit from reviewer run_id=389 2026-06-08 19:17:44 +03:00
a74379f657 feat(ORCH-026): task dependencies (B waits for A) + single-repo merge serialization
Level A — merge/deploy serialization within one repo: reuse the existing
ORCH-043/065 merge-lease (no new mechanism); the only new logic is an
unconditional pre-merge rebase in check_branch_mergeable — under the held
lease, auto_rebase_onto_main is ALWAYS called when premerge_rebase_always
(default True), not just when the branch is behind. No-op on an up-to-date
branch (rebase keeps HEAD, force-with-lease -> "Everything up-to-date", CI
not triggered). Kill-switch off -> ORCH-043 behaviour 1:1.

Level B — declarative task dependencies: additive job_deps table
(CREATE ... IF NOT EXISTS, no live-DB migration); claim_next_job gate
(NOT EXISTS) defers a job whose depends-on tasks are not yet 'done' without
occupying a max_concurrency slot; inert on empty job_deps -> zero regression.
New leaf src/task_deps.py (never-raise): is_task_ready (fail-open), DFS cycle
detection + Blocked/alert, declare/ingest_plane_relations (db source never
hits the network on the hot path), snapshot. Telegram waiting-line, /queue
observability, reconciler skip + cycle backstop, reaper untouched.

Invariants unchanged: STAGE_TRANSITIONS, QG_CHECKS registry (dep gate is a
claim_next_job врезка, not a registered QG), DB schema of existing tables,
HTTP endpoints; non-self repos remain a no-op on empty deps/scope.

Flags: ORCH_PREMERGE_REBASE_ALWAYS, ORCH_TASK_DEPS_ENABLED, ORCH_TASK_DEPS_SOURCE.
Docs: docs/architecture/README.md, CLAUDE.md, .env.example, CHANGELOG.md,
adr-0015. Tests: tests/test_orch026_*.py (64 tests); full suite 991 green.

Refs: ORCH-026

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 19:17:44 +03:00
9019e12d98 architect(ET): auto-commit from architect run_id=387 2026-06-08 19:17:44 +03:00
518d7d18c8 analyst(ET): auto-commit from analyst run_id=386 2026-06-08 19:17:44 +03:00
520bcafa73 docs: init ORCH-026 business request 2026-06-08 19:17:44 +03:00
9f7b6edb6d docs(ORCH-026): staging gate log — SUCCESS (8/10, C9a/C9b infra-waived)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 19:17:18 +03:00
1c3ecb973e Merge pull request 'ORCH-073: SHA-in-main merge-verify + main regression guard' (#77) from feature/ORCH-073-crit-main-orch-067-069 into main
Some checks failed
CI / test (push) Has been cancelled
2026-06-08 16:54:01 +03:00
deploy-finalizer
1b45fa0008 deploy(ORCH-036): finalize SUCCESS for ORCH-073
All checks were successful
CI / test (push) Successful in 21s
2026-06-08 16:54:00 +03:00
1f0929838a tester(ET): auto-commit from tester run_id=384
All checks were successful
CI / test (push) Successful in 26s
CI / test (pull_request) Successful in 21s
2026-06-08 16:30:46 +03:00
7deb151ce5 reviewer(ET): auto-commit from reviewer run_id=383 2026-06-08 16:30:46 +03:00
aff334e82b fix(merge-gate): SHA-in-main as sole merge-verify criterion + main regression guard
Root-cause fix for main erosion (phantom merge): code of ORCH-067/069 reached
`done` while absent from origin/main (only their auto docs-PRs landed).

- FR-1: verify_merged_to_main confirms merge ONLY by `git merge-base
  --is-ancestor <validated_sha> origin/main`; the OR-branch pr_already_merged is
  removed (a merged PR no longer confirms). Empty SHA / git error -> False.
- FR-2: pr_already_merged demoted to merge_pr idempotency-guard; counts a PR only
  when merged & head.ref==<branch> & base.ref=="main" (explicit in-loop filter).
- FR-3: merge_pr selects the open code-PR by head==<branch> AND base==main.
- FR-5: new deterministic check_main_regression in _handle_merge_verify (after
  confirmed SHA-in-main, before done) verifies MAIN_REGRESSION_MARKERS still in
  origin/main; deterministic count==0 -> alert "main regressed" + HOLD (NOT done,
  no rollback); git error of the grep -> fail-open. Kill-switch
  ORCH_REGRESSION_GUARD_ENABLED; non-self -> no-op.
- FR-4: root .gitattributes `CHANGELOG.md merge=union` so Unreleased edits
  auto-merge on rebase without conflict (branch not rolled back).

Invariants unchanged (STAGE_TRANSITIONS, QG_CHECKS, deploy-status, merge-gate,
image-freshness, DB schema, external HTTP API); non-self repos no-op (INV-5);
never-raise (INV-1); merge only via Gitea PR-API (INV-2).

Docs: CHANGELOG, .env.example (README/ADR updated by architect). Tests:
tests/test_orch073_*.py (TC-01..18); existing merge-gate tests updated for the
new code-PR filter.

Refs: ORCH-073

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 16:30:46 +03:00
fa9b96545c architect(ET): auto-commit from architect run_id=381 2026-06-08 16:30:46 +03:00
319b23b4fc analyst(ET): auto-commit from analyst run_id=380 2026-06-08 16:30:46 +03:00
e54d1fc4ac docs: init ORCH-073 business request 2026-06-08 16:30:46 +03:00
77abfb399c docs(ORCH-073): staging gate log — SUCCESS (8/10, C9a/C9b infra-waived)
Canonical run inside orchestrator-staging (ORCH-048): 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-08 16:30:19 +03:00
05bd169b14 Merge pull request 'restore(main): re-merge ORCH-067 + ORCH-069 (ORCH-073)' (#76) from restore/orch-6769-2026-06-08 into main
Some checks failed
CI / test (push) Has been cancelled
2026-06-08 15:04:47 +03:00
stream
183e6d68bc restore: re-merge ORCH-069 qg0_title_max
All checks were successful
CI / test (pull_request) Successful in 21s
# Conflicts:
#	CHANGELOG.md
2026-06-08 14:58:30 +03:00
stream
befa2979ec restore: re-merge ORCH-067 tracker bump+статусы+ссылки 2026-06-08 14:58:03 +03:00
deploy-finalizer
d33e0ded2e deploy(ORCH-036): finalize SUCCESS for ORCH-069
All checks were successful
CI / test (push) Successful in 23s
CI / test (pull_request) Successful in 20s
2026-06-08 11:44:38 +00:00
de70ee811d tester(ET): auto-commit from tester run_id=378
All checks were successful
CI / test (push) Successful in 20s
CI / test (pull_request) Successful in 20s
2026-06-08 11:30:17 +00:00
post-deploy-monitor
41da03470a docs(ORCH-021): post-deploy HEALTHY/NONE for ORCH-067
All checks were successful
CI / test (push) Successful in 22s
CI / test (pull_request) Successful in 21s
2026-06-08 11:28:18 +00:00
e1055861b5 reviewer(ET): auto-commit from reviewer run_id=377
All checks were successful
CI / test (push) Successful in 20s
CI / test (pull_request) Successful in 22s
2026-06-08 11:28:16 +00:00
2e84813c13 developer(ET): auto-commit from developer run_id=376
All checks were successful
CI / test (push) Successful in 20s
CI / test (pull_request) Successful in 20s
2026-06-08 11:25:09 +00:00
18f887c886 tester(ET): auto-commit from tester run_id=374
Some checks failed
CI / test (push) Has been cancelled
CI / test (pull_request) Successful in 21s
2026-06-08 11:24:01 +00:00
37ef58f21f reviewer(ET): auto-commit from reviewer run_id=373 2026-06-08 11:24:01 +00:00
0b9ae514c9 docs(qg0): add ORCH_QG0_TITLE_MAX to README config table
Reviewer P1 fix (attempt 2/3): новый параметр отсутствовал в таблице
«Все переменные с префиксом ORCH_», делая её неконсистентной заголовку.
Закрывает AC-6 / ТЗ §9.

Refs: ORCH-069
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 11:24:01 +00:00
c56672aabf reviewer(ET): auto-commit from reviewer run_id=371 2026-06-08 11:24:01 +00:00
0ed05417e6 feat(qg0): configurable QG-0 title limit via ORCH_QG0_TITLE_MAX (default 200)
Replace the hardcoded `len(name) > 80` cap in the QG-0 entry validation
(_qg0_errors) with a configurable Settings.qg0_title_max (env
ORCH_QG0_TITLE_MAX, default 200). The 80-char cap was a hygiene limit, not
structural, so valid 81-200 char titles were rejected without a business
reason. The limit is read dynamically per call and the error text interpolates
the active value.

Graceful degradation (AC-3, self-hosting safety): an empty/non-numeric env
value no longer crashes the process on startup. A field_validator(mode="before")
intercepts the raw env before int-parsing and falls back to 200 (never raises),
suppressing pydantic ValidationError.

Additive and backward-compatible (default 200 > old 80). Invariants unchanged:
STAGE_TRANSITIONS, QG_CHECKS registry, DB schema, slug [:30], lower limits,
soft-QG-0 warning path, API.

Refs: ORCH-069

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 11:24:01 +00:00
7d99782673 architect(ET): auto-commit from architect run_id=369 2026-06-08 11:23:26 +00:00
59603f6e92 analyst(ET): auto-commit from analyst run_id=350 2026-06-08 11:23:26 +00:00
d5f11e5caa docs: init ORCH-069 business request 2026-06-08 11:23:26 +00:00
affbb259a1 Merge pull request 'docs(ORCH-069): staging gate log — SUCCESS (8/10, C9a/C9b infra-waived)' (#75) from docs/ORCH-069-staging-log into main 2026-06-08 14:22:30 +03:00
8149eb7769 docs(ORCH-069): staging gate log — SUCCESS (8/10, C9a/C9b infra-waived)
All checks were successful
CI / test (pull_request) Successful in 21s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 11:22:22 +00:00
deploy-finalizer
9979eec168 deploy(ORCH-036): finalize SUCCESS for ORCH-067
All checks were successful
CI / test (push) Successful in 22s
CI / test (pull_request) Successful in 22s
2026-06-08 10:52:45 +00:00
c991b9de1a tester(ET): auto-commit from tester run_id=367
All checks were successful
CI / test (push) Successful in 27s
CI / test (pull_request) Successful in 24s
2026-06-08 10:34:33 +00:00
3d7d751b7a reviewer(ET): auto-commit from reviewer run_id=366 2026-06-08 10:34:33 +00:00
f330a580c4 docs(tracker): update CHANGELOG, CLAUDE.md, .env.example for ORCH-067
Закрывает 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:34:33 +00:00
896ecf6acb reviewer(ET): auto-commit from reviewer run_id=364 2026-06-08 10:34:33 +00:00
096c452230 developer(ET): auto-commit from developer run_id=363 2026-06-08 10:34:33 +00:00
9f176036f1 architect(ET): auto-commit from architect run_id=362 2026-06-08 10:34:33 +00:00
3e4191050f analyst(ET): auto-commit from analyst run_id=361 2026-06-08 10:34:33 +00:00
38e329f6f7 docs: init ORCH-067 business request 2026-06-08 10:34:33 +00:00
58d6c433d1 docs(ORCH-067): staging gate verdict SUCCESS
Merge 15-staging-log.md artifact into main (staging gate passed, exit 0).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 10:34:16 +00: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
124 changed files with 9974 additions and 95 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
@@ -36,6 +50,43 @@ ORCH_MERGE_RETEST_TARGET=tests/
ORCH_MERGE_LOCK_TIMEOUT_S=300
ORCH_MERGE_DEFER_DELAY_S=60
ORCH_MERGE_DEFER_MAX_ATTEMPTS=5
# ORCH-026 Level A: unconditional pre-merge rebase. With the flag ON (default),
# check_branch_mergeable ALWAYS rebases the branch onto origin/main under the held
# merge-lease (not only when behind) — a deterministic structural anti-phantom on
# the scheduler edge. No-op on an up-to-date branch (rebase keeps HEAD, force-with-
# lease -> "Everything up-to-date", CI not triggered). Scope = ORCH_MERGE_GATE_REPOS.
# PREMERGE_REBASE_ALWAYS=false -> strictly pre-ORCH-026 (rebase only when behind).
ORCH_PREMERGE_REBASE_ALWAYS=true
# ORCH-026 Level B: declarative task dependencies ("B waits for A"). claim_next_job
# gates jobs whose depends-on tasks are not yet 'done' (additive job_deps table,
# NOT EXISTS) WITHOUT occupying a max_concurrency slot. Inert on an empty job_deps.
# TASK_DEPS_ENABLED=false -> claim query is 1:1 the ORCH-1 query (no gate).
# TASK_DEPS_SOURCE=db|plane|hybrid -> declaration source; db (default) never calls
# Plane on the hot path; plane/hybrid ingest Plane `blocked-by` relations and
# cache them into job_deps (the scheduler then reads only the DB).
ORCH_TASK_DEPS_ENABLED=true
ORCH_TASK_DEPS_SOURCE=db
# ORCH-071/073: merge-verify under-gate on the `deploy -> done` edge (врезка in
# advance_stage, NOT a new STAGE_TRANSITIONS edge / registered QG). A deterministic
# merge-actor merges the feature code-PR via the Gitea PR-merge API (never push/
# force-push to main), then `done` is allowed ONLY when the deployed SHA is proven an
# ancestor of origin/main (ORCH-073 FR-1: SHA-in-main is the single criterion; a
# merged PR alone no longer confirms). A secondary regression guard then checks a
# declarative marker set (MAIN_REGRESSION_MARKERS) is still in origin/main; a missing
# marker -> alert + HOLD (NOT done), a git error of the grep itself -> fail-open.
# MERGE_VERIFY_ENABLED -> global kill-switch (false -> strictly pre-ORCH-071).
# MERGE_VERIFY_REPOS -> CSV of repos where the under-gate is REAL; empty ->
# only the self-hosting repo (orchestrator); non-self -> no-op.
# MERGE_PR_TIMEOUT_S -> per Gitea list/merge HTTP call timeout.
# MERGE_VERIFY_TIMEOUT_S -> git fetch/merge-base timeout for the ancestor + marker checks.
# REGRESSION_GUARD_ENABLED -> kill-switch for the ORCH-073 main-integrity regression
# guard (false -> SHA-in-main alone gates done); reuses the
# merge-verify scope, so non-self repos are a no-op.
ORCH_MERGE_VERIFY_ENABLED=true
ORCH_MERGE_VERIFY_REPOS=
ORCH_MERGE_PR_TIMEOUT_S=60
ORCH_MERGE_VERIFY_TIMEOUT_S=60
ORCH_REGRESSION_GUARD_ENABLED=true
# ORCH-036: executable self-deploy of the `deploy` stage. For the self-hosting repo
# (orchestrator) the stage REALLY restarts prod (8500) via a detached host hook;
# deploy_status: SUCCESS means proven health-ok, not an LLM declaration. Three
@@ -199,3 +250,10 @@ ORCH_POST_DEPLOY_FAIL_THRESHOLD=3
ORCH_POST_DEPLOY_5XX_THRESHOLD=0.5
ORCH_POST_DEPLOY_AUTO_ROLLBACK=false
ORCH_POST_DEPLOY_BASE_URL=http://localhost:8500
# ── QG-0 entry validation (ORCH-069) ──────────────────────────────────────────
# Upper title-length limit for the QG-0 entry gate (_qg0_errors). The old 80-char
# cap was a hygiene limit, not structural (slug is cut to [:30] independently, the
# DB title TEXT is unbounded). Default 200. An invalid/empty value gracefully
# degrades to 200 (the process never crashes on startup).
ORCH_QG0_TITLE_MAX=200

View File

@@ -50,3 +50,6 @@ ORCH_QUEUE_POLL_INTERVAL=2.0
DEPLOY_SSH_USER=slin
DEPLOY_SSH_HOST=127.0.0.1
DEPLOY_HOOK_SCRIPT=/home/slin/bin/enduro-deploy-hook.sh
# QG-0 entry title-length limit (ORCH-069). Default 200; invalid/empty -> 200.
ORCH_QG0_TITLE_MAX=200

13
.gitattributes vendored Normal file
View File

@@ -0,0 +1,13 @@
# ORCH-073 (ADR-001 Р-5 / FR-4): union merge for the append-only changelog.
#
# CHANGELOG.md is append-only at the top (## [Unreleased]). Without a merge driver,
# two branches that both add an Unreleased entry collide on auto_rebase_onto_main
# (merge_gate), which rolls the branch back to `development` and can drag in stale
# neighbouring code (a phantom-merge amplifier — see ADR-001 root cause #3). The
# built-in `union` driver keeps BOTH sides' lines instead of conflicting, so both
# changelog entries survive and the branch is not rolled back.
#
# Scope is INTENTIONALLY limited to CHANGELOG.md: `union` only suits strictly
# append-only files. docs/**/*.md (README, ADR, internals) are rewritten line-by-line,
# where `union` would silently duplicate edited lines — so they are NOT included.
CHANGELOG.md merge=union

File diff suppressed because one or more lines are too long

View File

@@ -7,7 +7,7 @@
- Backend: FastAPI + uvicorn (Python 3.12)
- БД: SQLite (`src/db.py`)
- Агенты: Claude CLI (`ORCH_CLAUDE_BIN`), по одному промпту на роль в `.openclaw/agents/`
- Очередь задач: собственная (SQLite `jobs`, `src/queue_worker.py`, ORCH-1)
- Очередь задач: собственная (SQLite `jobs`, `src/queue_worker.py`, ORCH-1). **ORCH-026:** `claim_next_job` гейтит задачи с незавершёнными зависимостями (`job_deps`, `NOT EXISTS`) без занятия слота `max_concurrency`; декларации/детект циклов — leaf `src/task_deps.py` (kill-switch `ORCH_TASK_DEPS_ENABLED`). Сериализация мержа одного репо — безусловный pre-merge rebase под merge-lease (`ORCH_PREMERGE_REBASE_ALWAYS`).
- Контейнеризация: Docker + Compose
- CI/CD: Gitea Actions (`.gitea/workflows/`)
- Деплой: docker compose на mva154
@@ -41,6 +41,22 @@ created → analysis → architecture → development → review → testing →
## Статусная модель Plane (ORCH-066) — индикация ≠ управление
Статусы Plane — это **слой B (индикация)**, отдельный от **слоя A (машина стадий)** `src/stages.py::STAGE_TRANSITIONS`. Plane показывает наблюдателю осмысленную картину (`Backlog → Todo → Analysis → Architecture → Development → Code-Review → Testing → Awaiting Deploy → Deploying → Monitoring after Deploy → Done` + человеческие гейты `In Review/Approved`, `Confirm Deploy`), но НИКОГДА не управляет конвейером. Маппинг и сеттеры — `src/plane_sync.py` (6 новых ключей: `to_analyse/analysis/code_review/awaiting_deploy/deploying/monitoring`), с project-relative alias-fallback: на частично сконфигурированном проекте новый ключ деградирует на базовый UUID ТОГО ЖЕ проекта (нулевая регрессия для enduro-trails). Детали — `docs/architecture/README.md`.
## Нотификации / Telegram live-tracker (ORCH-042/066/067)
Каждая задача = **одна карточка** в Telegram (`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`

View File

@@ -136,6 +136,7 @@ uvicorn src.main:app --reload --port 8500
| `ORCH_RECONCILE_GRACE_OVERRIDES_JSON` | Per-stage пороги, напр. `{"development":300}` | `""` |
| `ORCH_RECONCILE_NOTIFY_UNBLOCK` | Telegram при разблокировке застрявшей задачи | `true` |
| `ORCH_RECONCILE_SKIP_BLOCKED_ENABLED` | F-1 Guard 2 (ORCH-060): пропуск задач в Plane-статусе Blocked / Needs Input; `false` глушит только сетевой Guard 2 (Guard 1 escalated всегда активен) | `true` |
| `ORCH_QG0_TITLE_MAX` | Верхний лимит длины заголовка QG-0 (вход `_qg0_errors`); невалидное/пустое значение → дефолт (ORCH-069) | `200` |
## Очередь задач (ORCH-1 / F-2b)

View File

@@ -10,9 +10,10 @@
- **Review/Test Parsers** (`src/review_parse.py`, ORCH-046) — defensive-извлечение дословного must-fix текста из артефактов для встраивания в `task_desc` заворота: `extract_review_findings` (P0/P1 из `12-review.md`), `extract_test_failures` (фрагмент тела `13-test-report.md`). Контракт «never raise»: любая ошибка → `""`.
- **Quality Gates** (`src/qg/checks.py`) — проверки выхода со стадии, реестр `QG_CHECKS`.
- **Agent Launcher** (`src/agents/launcher.py`) — запуск Claude CLI агентов в изолированном git worktree, мониторинг, auto-advance.
- **Queue** (`src/queue_worker.py`, ORCH-1) — персистентная очередь задач (SQLite `jobs`), atomic claim, max_concurrency, ретраи, restart-safe.
- **Queue** (`src/queue_worker.py`, ORCH-1) — персистентная очередь задач (SQLite `jobs`), atomic claim, max_concurrency, ретраи, restart-safe. **ORCH-026:** `claim_next_job` гейтит задачи с незавершёнными зависимостями (`job_deps`, `NOT EXISTS`) без занятия слота; декларации/циклы — leaf `src/task_deps.py`.
- **Job-reaper** (`src/job_reaper.py`, ORCH-065 — [adr-0011](adr/adr-0011-job-reaper-lease-reclaim.md)) — фоновый daemon-поток (каркас `reconciler`), стартует/останавливается в `main.lifespan` (после `reconciler.start()` / перед `worker.stop()`). Детектирует «мёртвый» `running`-job **без рестарта** процесса (Tier-1 мёртвый `jobs.pid` после `reaper_dead_ticks` тиков; Tier-2 `agent_runs.exit_code` записан, а job ещё `running`; Tier-3 backstop `reaper_max_running_s`) и приводит строку к корректному статусу через те же контракты (`_try_advance_stage`/`_finalize_job`, gate-driven; exit≠0/неизвестно → `attempts<max``queued`, иначе `failed`+Telegram). Атомарный reap-claim (guard `status='running'`) совместим со стартовым `requeue_running_jobs`. Тот же поток периодически делает проактивный реклейм stale/dead merge-lease (см. ниже). never-raise; kill-switch `ORCH_REAPER_ENABLED`; снимок в `GET /queue` (блок `reaper`).
- **Reconciler** (`src/reconciler.py`, ORCH-053 — реализовано, [adr-0007](adr/adr-0007-reconciler.md)) — фоновый daemon-поток (паттерн `queue_worker`), стартует/останавливается в `main.lifespan` (после `worker.start()` / перед `worker.stop()`). Реконсилирует рассинхрон «источник истины ≠ стадия задачи» при потерянном webhook. F-1 gate-side (продвигает застрявшую стадию по локальной БД через штатный `advance_stage(..., finished_agent=None)`), F-2 plane-side (опрос Plane API → `handle_*` из `plane.py`), F-3 (БД-fallback `sha→branch` в `handle_ci_status`). Источник истины — гейт/Plane, не событие; идемпотентность (active-job guard + atomic-claim + grace); kill-switch `ORCH_RECONCILE_ENABLED`. `analysis` F-1 не трогает (человеческий гейт). F-1 также пропускает escalated (retry≥лимита) и Blocked/Needs-Input задачи (ORCH-060). Наблюдаемость — блок `reconcile` в `GET /queue`.
- **Notifications / Live-tracker** (`src/notifications.py`, ORCH-042/ORCH-067) — ОДНА live-карточка на задачу (`update_task_tracker`), обновляется на каждом переходе. Режим `ORCH_TRACKER_MODE` (дефолт `bump` с ORCH-067: delete+silent send+repoint внизу чата; `edit` — правка на месте). Карточка несёт строку Plane-статуса `📍 …` (оффлайн-ядро `plane_status_label` + best-effort live-overlay `_live_plane_branch_override`, kill-switch `ORCH_TRACKER_LIVE_STATUS`) и кликабельный номер задачи (`plane_issue_link`/`link_for` → ссылка в Plane, fail-safe на сырой номер). Все алерты, упоминающие `work_item_id`, делают номер кликабельным. Контракт всего компонента — never raises; карточка всегда silent. Детали — [internals.md](internals.md) §7.
- **Project Registry** (`src/projects.py`, ORCH-6) — Plane project id → repo + prefix; фильтрация вебхуков по проекту.
- **Plane Sync** (`src/plane_sync.py`) — синхронизация статусов/комментариев в Plane. Резолв статусов проекта `get_project_states` (ORCH-10) кэширует `{logical_key→uuid}` per-project; **ORCH-068** добавляет в кэш-запись `{uuid→group}` (для терминал-исключения F-2) и **TTL** `ORCH_PLANE_STATES_TTL_S` (дефолт 300с; `0` → прежний lifetime-кэш) — устаревший набор статусов самозалечивается без рестарта процесса через существующий `reload_project_states()` (баг кэша после появления нового Plane-статуса). Форма возврата `get_project_states` неизменна (обратная совместимость).
@@ -58,11 +59,24 @@ Self-hosting зацикливался на `deploy-staging`: `scripts/staging_ch
Назначение: ветка валидируется относительно того `main`, из которого создана; параллельная задача могла уйти вперёд → семантический конфликт слияния (зелёная ветка ломает обновлённый `main`). Merge-gate гарантирует проверку против **актуального** `origin/main` перед слиянием:
- **Догон:** ветка отстаёт (⇔ `origin/main` не предок HEAD) → `rebase origin/main` в worktree + `push --force-with-lease` (ТОЛЬКО ветка задачи; `main` — никогда). Текстовый конфликт → `rebase --abort` → откат на `development`.
- **Безусловный pre-merge rebase (ORCH-026, A-2):** при `premerge_rebase_always` (дефолт `True`, скоуп `merge_gate_repos`) short-circuit `branch_is_behind_main` пропускается — `auto_rebase_onto_main` вызывается **всегда** под лизом. На актуальной ветке это no-op (`rebase` не меняет HEAD, `push --force-with-lease` → «Everything up-to-date», CI не триггерится); на отстающей — реальный догон. Детерминированный структурный анти-фантом на уровне планировщика (дополняет рубежи ORCH-073, не заменяет). Kill-switch `premerge_rebase_always=False` → прежнее поведение (ребейз только при behind).
- **Re-test:** `python -m pytest` (`merge_retest_target`, дефолт `tests/`) в worktree догнанной ветки, тайм-аут `merge_retest_timeout_s`. Красный/тайм-аут → откат на `development`.
- **Сериализация (merge-lock):** файловый **merge-lease** на репо (`<repos_dir>/.merge-lease-<repo>.json`), живёт от гейта до фактического merge. Acquire **неблокирующий** (anti-deadlock при `max_concurrency=1`): busy → **defer** (повторная постановка deployer'а на `deploy-staging` с задержкой через `available_at`), а не откат. Release — на PR-merged вебхуке / `deploy→done` / откате / по возрасту (crash-реклейм). Restart-safe; без изменения схемы БД.
- **Сериализация (merge-lock):** файловый **merge-lease** на репо (`<repos_dir>/.merge-lease-<repo>.json`), живёт от гейта до фактического merge. Acquire **неблокирующий** (anti-deadlock при `max_concurrency=1`): busy → **defer** (повторная постановка deployer'а на `deploy-staging` с задержкой через `available_at`), а не откат. Release — на PR-merged вебхуке / `deploy→done` / откате / по возрасту (crash-реклейм). Restart-safe; без изменения схемы БД. **ORCH-026 (A-1):** это окно = «merge → main-updated» (для self `done` ⇔ SHA-in-main, ORCH-073) — пока A не в `main`, B того же репо получает `merge-lock busy` → defer. Окно сериализации per-repo НЕ переписывается; кросс-репо параллелизм сохранён (лиз — per-repo файл).
- **Условность (как ORCH-35):** реален для `orchestrator`; прочие репо — no-op. Флаги `merge_gate_enabled` / `merge_gate_repos` — поэтапный раскат. Контракт **never-raise**.
Подробнее: [adr-0006](adr/adr-0006-merge-gate.md), детально — `docs/work-items/ORCH-043/06-adr/ADR-001-merge-gate.md`.
Безусловный pre-merge rebase + связь с зависимостями задач — [adr-0015](adr/adr-0015-task-deps-and-merge-serialization.md) (ORCH-026).
### Зависимости задач: B ждёт A (ORCH-026, Уровень B)
Плоская очередь ORCH-1 (FIFO по `id` + `available_at` + `max_concurrency`) не выражала логических зависимостей. ORCH-026 вводит декларативные связи «задача B не стартует, пока не готовы её depends-on» — без новой стадии и без изменения `STAGE_TRANSITIONS`/`QG_CHECKS`.
- **Источник истины планировщика — БД** (аддитивная таблица `job_deps(task_id, depends_on_task_id)`): claim в горячем цикле обслуживает очередь ВСЕХ проектов и обязан быть offline-устойчив (сетевой Plane на каждый claim = встанет очередь всех проектов). Источник **декларации** настраивается `task_deps_source = db|plane|hybrid` (дефолт `db`; `plane`/`hybrid` читают Plane relations в `handle_work_item_created` и кэшируют в `job_deps`).
- **Гейт планировщика (`claim_next_job`)** — условие `NOT EXISTS (job_deps d JOIN tasks t … WHERE d.task_id=j.task_id AND t.stage!='done')` при `task_deps_enabled`: задача с незавершённой зависимостью **не выбирается** (агент не запускается, слот `max_concurrency` не занимается). Инертно при пустой `job_deps` → нулевая регрессия; kill-switch `task_deps_enabled=False` → запрос 1:1 как ORCH-1.
- **Детект дедлоков** — DFS-цикл-детектор (leaf `src/task_deps.py::detect_cycle`) при вставке связи + backstop в `reconciler`; цикл → `set_issue_blocked` + alert (Telegram/Plane) с перечислением цикла. Поток остальных задач не блокируется.
- **Видимость** — строка «⏳ ждёт ORCH-NNN» в Telegram-карточке (`update_task_tracker`, never-raise); Plane `Blocked` — на дедлоке (не на нормальном коротком ожидании, чтобы не флаппить). Инвариант «одна карточка на задачу» сохранён.
- **Совместимость:** `reconciler` F-1 пропускает dep-заблокированные задачи (`is_task_ready`, паттерн ORCH-060); `reaper` сканирует только `running` → dep-блок остаётся `queued`, не трогается. Зависимости — только intra-repo (v1).
- **Наблюдаемость:** блок `task_deps` в `GET /queue` (заблокированные задачи, держатель merge-lease, defer-счётчики, обнаруженные циклы) — read-only.
Подробнее: [adr-0015](adr/adr-0015-task-deps-and-merge-serialization.md), детально — `docs/work-items/ORCH-026/06-adr/ADR-001-merge-serialization-and-task-deps.md`.
### Исполняемый самодеплой стадии `deploy` (ORCH-36)
`deploy` перестаёт быть «бумажной»: для self-hosting (`is_self_hosting_repo`) стадия
@@ -121,6 +135,63 @@ 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` (idempotency no-op повтор) → иначе
Gitea `POST /repos/{owner}/{repo}/pulls/{index}/merge`. Выбор PR строго по `head.ref==branch`
И `base.ref=="main"`. Никогда push/force-push в `main`.
- **Верификатор `merge_gate.verify_merged_to_main` (семантика ORCH-073, FR-1):** подтверждение —
**ТОЛЬКО** `git merge-base --is-ancestor <validated_sha> origin/main` (`validated_revision`
якорь ORCH-058). PR-флаг `pr_already_merged` **больше НЕ подтверждает merge** (удалён из verify):
он понижен до idempotency-guard `merge_pr` и засчитывает merged PR лишь при `head.ref==branch`
И `base.ref=="main"` (исключает авто docs-PR). Пустой SHA / git-ошибка → `False` (fail-closed),
never-raise.
- **Регресс-гард целостности `main` (ORCH-073, FR-5):** `merge_gate.check_main_regression` в
`_handle_merge_verify` ПОСЛЕ подтверждённого SHA-в-main и ДО `done` проверяет, что `origin/main`
содержит декларативный набор маркеров ранее-merged задач (`MAIN_REGRESSION_MARKERS`,
`git grep -c <marker> origin/main -- <path>` > 0). Маркер отсутствует → alert «main regressed» +
HOLD (НЕ `done`, ALERT-only). Fail-open на git-ошибке грепа (регресс — только при `count==0`).
Kill-switch `regression_guard_enabled`; non-self → no-op. Набор — append-only константа,
значимая задача дописывает свой маркер.
- **Не подтверждено → 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:` нетронут).
- **Защита от CHANGELOG-затирания (ORCH-073, FR-4):** корневой `.gitattributes` с
`CHANGELOG.md merge=union` → правки `## [Unreleased]` авто-сливаются при `auto_rebase_onto_main`
без конфликта, ветка не откатывается в `development` и не тащит устаревший код-сосед. `docs/**`
под union НЕ ставится (union только для append-only).
- **Условность как ORCH-35/43/58:** `merge_verify_enabled` (kill-switch, дефолт `true`) +
`merge_verify_repos` (пусто → только self-hosting); non-self — no-op, merge остаётся за `deployer`.
never-raise; идемпотентность по **SHA-в-main** (INV-4, не «любой merged PR»); ручной 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) +
[adr-0014](adr/adr-0014-merge-verify-sha-source-of-truth.md) (amends 0013 — SHA-в-main как
единственный критерий + регресс-гард, ORCH-073); детально —
`docs/work-items/ORCH-071/06-adr/ADR-001-merge-verify-gate.md`,
`docs/work-items/ORCH-073/06-adr/ADR-001-merge-verify-sha-truth-and-regression-guard.md`.
### Post-deploy наблюдение прода + реакция на деградацию (ORCH-021 — реализовано)
Конвейер заканчивался на `deploy → done` и **забывал про прод**: «успех» = health-check
в момент рестарта (~60с). Класс «зелёный деплой, красный прод» (прецедент ET-8 —
@@ -394,6 +465,7 @@ Monitoring after Deploy → Done
- `tasks` — задачи и их стадии
- `agent_runs` — запуски агентов (run_id, usage, cost)
- `jobs` — очередь задач (ORCH-1); колонка `pid` (ORCH-065) — pid агентского процесса для liveness-детекции зомби job-reaper'ом
- `job_deps` — декларативные зависимости задач (ORCH-026, Уровень B): `(task_id, depends_on_task_id)`, аддитивная; источник истины планировщика для гейта «B ждёт A»
## Изоляция (git worktree, ORCH-2)
Каждая задача исполняется в отдельном git worktree, ветки не пересекаются. Репозитории проектов разделены под `/repos/<project>`.

View File

@@ -17,11 +17,16 @@ Per-work-item решения живут в `docs/work-items/<id>/06-adr/ADR-NNN-
| adr-0009 | Толерантность staging-вердикта к инфраструктурным FAIL | accepted | 2026-06-07 | ORCH-061 |
| adr-0010 | Post-deploy мониторинг прода + реакция на деградацию | proposed | 2026-06-07 | ORCH-021 |
| adr-0011 | Job-reaper + проактивный реклейм merge-lease | accepted | 2026-06-07 | ORCH-065 |
| adr-0012 | Security-гейт (secrets/deps) | accepted | 2026-06-08 | ORCH-022 |
| adr-0013 | Merge-в-main + пост-деплой верификация как условие `done` | accepted | 2026-06-08 | ORCH-071 |
| adr-0014 | SHA-в-main — единственный критерий merge-verify + регресс-гард | accepted | 2026-06-08 | ORCH-073 |
| adr-0015 | Зависимости задач (B ждёт A) + сериализация merge внутри репо | accepted | 2026-06-08 | ORCH-026 |
> ⚠️ Историческая коллизия: номер `0007` занят двумя файлами —
> `adr-0007-reconciler.md` (ORCH-053) и `adr-0007-executable-self-deploy.md`
> (ORCH-036). Оба accepted; для новых сквозных ADR использовать следующий
> свободный номер (текущий максимум — `0011`).
> свободный номер (текущий максимум — `0015`).
> adr-0014 **amends** adr-0013 (меняет критерий merge-verify на «SHA-в-main»).
## Формат
**Контекст → Решение → Альтернативы → Последствия → Связи.** Статус: proposed / accepted / superseded.

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

@@ -0,0 +1,77 @@
# adr-0014: SHA-в-main — единственный критерий merge-verify + регресс-гард целостности `main`
- **Статус:** accepted
- **Дата:** 2026-06-08
- **Задача:** ORCH-073 (BUG CRITICAL — эрозия `main`)
- **Amends:** [adr-0013](adr-0013-merge-verify-gate.md) (ORCH-071) — меняет КРИТЕРИЙ подтверждения merge.
- **Детальный ADR:** `docs/work-items/ORCH-073/06-adr/ADR-001-merge-verify-sha-truth-and-regression-guard.md`
- **Постмортем:** `docs/history/LESSONS_2026-06-08_phantom-merge.md`
## Контекст
adr-0013 (ORCH-071) ввёл под-гейт merge-verify на ребре `deploy → done`, но допускал
подтверждение merge по **ИЛИ-критерию**: `verify_merged_to_main` возвращал `True`, если
`pr_already_merged(repo, branch)` **ЛИБО** SHA — предок `origin/main`. `pr_already_merged`
засчитывал **любой** merged PR ветки, включая авто docs-PR (staging/deploy-логи). У одной
feature-ветки в `main` сливались только docs-PR, а code-PR — нет → `pr_already_merged`=`True`
verify `CONFIRMED``done`, хотя кода в `main` не было. Накопительно потеряны ORCH-067 (ссылки
`plane_issue_link`) и ORCH-069 (`qg0_title_max`). Вторичный усилитель — CHANGELOG-ребейзы,
откатывающие ветку и тащащие устаревший код-сосед. Восстановление кода (G1) выполнено вручную
restore-PR #76; этот ADR устраняет корень навсегда.
## Решение
1. **SHA-в-main — единственный критерий (FR-1).** `verify_merged_to_main(repo, branch, sha)`
подтверждает merge **ТОЛЬКО** прямым фактом `git merge-base --is-ancestor <sha> origin/main`
(после `git fetch origin main`). OR-ветка `pr_already_merged` **удалена** из верификатора.
Пустой `sha` / любая git-ошибка → `False` (fail-closed: alert + HOLD). never-raise (INV-1).
2. **`pr_already_merged` → idempotency-guard, различающий code-PR/docs-PR (FR-2).** Засчитывает
merged PR только при `head.ref==<feature-branch>` И `base.ref=="main"` (явный фильтр в цикле,
не ненадёжный query-параметр `head`). Используется лишь как защита `merge_pr` от второго merge,
НЕ как подтверждение `done`.
3. **`merge_pr` сливает именно code-ветку (FR-3).** Выбор открытого PR по `head.ref==branch` И
`base.ref=="main"`; merge только Gitea `POST /pulls/{index}/merge`, никогда push/force-push в
`main`. Источник истины «слилось» — FR-1.
4. **Регресс-гард целостности `main` (FR-5).** Новая `merge_gate.check_main_regression`,
вызываемая в `_handle_merge_verify` ПОСЛЕ подтверждённого SHA-в-main и ДО `done`: проверяет, что
`origin/main` содержит **декларативный набор маркеров** ключевых функций ранее-merged задач
(`git grep -c <marker> origin/main -- <path>` > 0). Маркер отсутствует → **alert «main
regressed» + HOLD** (НЕ `done`, БЕЗ авто-отката на `development` — инфра-дефект, ALERT-only как
ORCH-021/071). Набор — append-only константа `MAIN_REGRESSION_MARKERS` в `merge_gate.py`
(расширяется каждой значимой задачей). **Fail-open** на git-ошибке самого грепа (регресс
утверждается только при детерминированном `count==0`); первичный фейл-клозед — SHA-в-main.
Kill-switch `regression_guard_enabled` (дефолт `true`); non-self → no-op.
5. **`.gitattributes CHANGELOG.md merge=union` (FR-4).** В корне репо; авто-слияние правок
`## [Unreleased]` без конфликта → `auto_rebase_onto_main` не откатывает ветку и не тащит
устаревший код-сосед. `docs/**/*.md` под union **НЕ** ставится (union только для append-only;
доки переписываются построчно).
## Инварианты
never-raise на verify/merge/регресс-гарде (ошибка → alert/HOLD, не падение); прод 8500 не
рестартится/не падает в рамках merge; merge только Gitea PR-API без force-push в `main`; ручной
`Confirm Deploy` (ORCH-059) сохранён; идемпотентность по «SHA-в-main», а не по «любому merged PR»;
non-self репо (enduro) — merge/verify/регресс-гард без изменений. `STAGE_TRANSITIONS`, реестр
`QG_CHECKS`, `check_deploy_status`, схема БД, внешние HTTP-эндпоинты — **без изменений**.
## Альтернативы
- Сохранить PR-флаг как со-критерий verify (с фильтром head/base) — отклонено: PR можно слить и
тут же откатить ребейзом-соседом; надёжен только факт «SHA в main».
- `docs/**/*.md merge=union` — отклонено: тихая дубликация строк в переписываемых доках.
- Регресс-гард с авто-откатом / хранением маркеров в БД/Plane — отклонено (Не-цель «не менять
схему БД/Plane»; реакция ALERT-only).
- Fail-closed на marker-grep — отклонено: ложный HOLD при git-сбое; marker-grep вторичен.
## Последствия
Невозможно «`done` + прод задеплоен, а code-PR не в `main`». Ложно-зелёный по docs-PR устранён в
корне. CHANGELOG-конфликты больше не откатывают ветку. Регресс соседнего кода ловится отдельным
гардом. Минус: при недоступной Gitea/git verify консервативно `False` → возможен ложный HOLD+alert
(снимается повтором; fail-closed для `done` приоритетен). Набор маркеров требует дисциплины —
значимая задача дописывает свой маркер.
## Связи
- Amends adr-0013 (ORCH-071), наследует adr-0006 (merge-gate), adr-0011 (job-reaper/lease).
- Детально: `docs/work-items/ORCH-073/06-adr/ADR-001-merge-verify-sha-truth-and-regression-guard.md`.

View File

@@ -0,0 +1,47 @@
# adr-0015: Зависимости задач + сериализация merge внутри репо
**Статус:** accepted · **Дата:** 2026-06-08 · **Источник:** ORCH-026
**Связи:** дополняет adr-0006 (merge-gate), adr-0011 (merge-lease + reclaim), adr-0013/0014
(merge-verify, SHA-in-main), adr-0002 (очередь). Детально —
`docs/work-items/ORCH-026/06-adr/ADR-001-merge-serialization-and-task-deps.md`.
## Контекст
Эрозия `main` 08.06 родилась из некоординированного параллелизма задач одного репо (ветки от
устаревшего `main`, фантом-merge затирает соседа). adr-0014 закрыл последствия; ORCH-026 — корень
на уровне планировщика. Плюс исходный скоуп ORCH-026: декларативные зависимости задач (B ждёт A).
## Решение
**Уровень A — сериализация merge/деплоя (per-repo).** Окно сериализации уже обеспечивается
merge-lease (adr-0011): захват в `check_branch_mergeable`, удержание до release (PR-merged webhook /
`deploy→done`=SHA-in-main для self / откат / проактивный reclaim). Это и есть окно
«merge → main-updated» — **механизм не переписывается**. Добавляется единственное новое поведение:
**безусловный proactive pre-merge rebase** (флаг `premerge_rebase_always`, дефолт `True`, скоуп
`merge_gate_repos`): под лизом всегда вызывается `auto_rebase_onto_main` (no-op + «Everything
up-to-date» на актуальной ветке → CI не триггерится; реальный догон на отстающей). Инвариант:
никаких push в `main`, force только `--force-with-lease` на ветку.
**Уровень B — декларативные зависимости.** Аддитивная таблица `job_deps(task_id,
depends_on_task_id)`**источник истины планировщика** (offline-устойчивость: сетевой Plane в
горячем claim встанет очередью всех проектов). Источник декларации настраивается
`task_deps_source = db|plane|hybrid` (дефолт `db`); планировщик всегда читает БД-кэш. Гейт —
условие `NOT EXISTS` в `claim_next_job` (задача не выбирается, пока есть незавершённая зависимость;
слот `max_concurrency` не занимается). Циклы — DFS-детектор (`src/task_deps.py`) + `set_issue_blocked`
+ alert. Видимость — строка «⏳ ждёт ORCH-NNN» в Telegram-карточке (Plane Blocked — на дедлоке).
Зависимости — только intra-repo (v1).
## Альтернативы
Отдельный merge-lock/merge-queue (дублирует adr-0011); расширение release-точек лиза (не нужно —
окно уже корректно); Plane как источник истины планировщика (self-hosting risk); гейт зависимостей
в воркере с claim+requeue (churn vs. чистый `NOT EXISTS`); поле в `tasks` вместо таблицы (M:N хуже).
## Последствия
Минимально-инвазивно: `STAGE_TRANSITIONS`/`QG_CHECKS` не тронуты (паттерн врезки), переиспользует
merge-gate/merge-lease целиком. Обе фичи инертны без данных → нулевая регрессия для enduro-trails.
restart-safe, never-raise, kill-switch на каждую (`premerge_rebase_always`, `task_deps_enabled`).
Миграция — только аддитивная (`CREATE TABLE/INDEX IF NOT EXISTS`). Ограничение: B v1 — intra-repo.
Self-hosting safety: изменения идут через `deploy-staging``Confirm Deploy`, без внеочередного
рестарта прода.

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,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: Управление зависимостями задач (B ждёт A) в очереди
Work Item ID: ORCH-026
## Description
TBD

View File

@@ -0,0 +1,135 @@
# 01-BRD — Управление зависимостями задач (B ждёт A) в очереди
**Work Item:** ORCH-026
**Repo:** orchestrator (self-hosting)
**Branch:** feature/ORCH-026-b-a
**Стадия:** analysis
**Источник:** предложение Стрим, одобрено Славой (2026-06-04); дополнение Слава+Стрим 2026-06-08 (инцидент эрозии `main`)
---
## 1. Контекст и проблема
### 1.1 Первопричина (мотивация СЕЙЧАС — инцидент 08.06)
Эрозия `main` 08.06 (потеря кода ORCH-067/069, фантом-merge) родилась НЕ из логических
зависимостей, а из **некоординированного параллелизма**: несколько self-hosting задач
(ORCH-067/069/071) одновременно срезали ветки от `main` и правили общие файлы
(`CHANGELOG.md`, `notifications.py`, `config.py`). Последствия:
- CHANGELOG-конфликты на `auto_rebase` → откаты `deploy-staging → development` (дорого:
ORCH-069 = 3 попытки = $3.98);
- тихое затирание кода соседа при merge ветки, срезанной от устаревшего `main` (фантом).
**ORCH-073** закрыл ПОСЛЕДСТВИЯ (3 рубежа: CHANGELOG `merge=union` + SHA-in-main verify +
регресс-гард маркеров). ORCH-026 должен закрыть **ПЕРВОПРИЧИНУ**: задачи одного репо не
должны мешать друг другу в `main`.
### 1.2 Исходный скоуп (плоская очередь ORCH-1)
Очередь (`src/queue_worker.py`, ORCH-1) — плоская: `jobs` упорядочены по `id` (FIFO),
гейтятся только `available_at` и `max_concurrency`. Нельзя выразить «задача B не стартует,
пока не готова A». Декомпозиция эпиков (ORCH-025) порождает заведомо зависимые подзадачи.
### 1.3 Что уже есть (опора, НЕ переписывать)
- **ORCH-1** — персистентная очередь (`jobs`), atomic claim, `available_at`-defer, restart-safe.
- **ORCH-065** — `merge-lease` (`src/merge_gate.py`): per-repo файловый лиз
`.merge-lease-<repo>.json`, неблокирующий acquire, holder-aware release, проактивный
реклейм мёртвого/устаревшего держателя. **Сейчас лиз держится только на ребре
`deploy-staging → deploy`** (от merge-gate до фактического merge).
- **ORCH-043** — merge-gate: `branch_is_behind_main`, `auto_rebase_onto_main` (rebase
**только когда ветка отстаёт или при конфликте**), `retest_branch`.
- **ORCH-073** — merge-verify: `verify_merged_to_main` (SHA-in-main), `check_main_regression`.
- **Plane-статусы** `Blocked` / `Needs Input` + `set_issue_blocked` (`src/plane_sync.py`).
- **Telegram live-tracker** (`src/notifications.py`) — одна карточка на задачу, уже умеет
показывать статус `Blocked`.
---
## 2. Цель (бизнес-результат)
Задачи одного репозитория перестают повреждать `main` друг друга, а очередь умеет
выражать логические зависимости между задачами — БЕЗ потери параллелизма между разными
репозиториями и без риска для self-hosting прода.
---
## 3. Два уровня требований (объединить в одной задаче; приоритет — Уровень A)
### Уровень A — Сериализация merge/деплоя внутри ОДНОГО репо (КРИТИЧНО, корень эрозии)
Закрывает первопричину инцидента 08.06.
- **A-1.** В рамках ОДНОГО репо merge-в-`main` + деплой должны быть **сериализованы**: пока
задача A не слита в `main` (и для self-hosting — не задеплоена), задача B того же репо НЕ
доходит до своего merge/деплоя от устаревшего `main`.
- **A-2.** B перед своим merge-gate **обязана ребейзнуться на СВЕЖИЙ `main`** (где уже есть
A) — **proactive pre-merge rebase**, а не только при текстовом конфликте (как сейчас в
ORCH-043). Цель: B всегда несёт актуальный код предшественников → структурный анти-фантом
на уровне планировщика (дополняет рубежи ORCH-073, не заменяет).
- **A-3.** Сериализация — **только внутри одного репо**. Задачи РАЗНЫХ репо (orchestrator vs
enduro-trails) параллелятся свободно (общая БД/очередь — пропускная способность не падает).
- **A-4.** Механизм — минимально-инвазивный и **restart-safe** (как ORCH-1/065): переживает
рестарт прод-контейнера, не оставляет навсегда захваченных ресурсов (опора на проактивный
реклейм ORCH-065).
- **A-5.** **Совместимость с self-hosting safety:** не ронять/не рестартить прод-контейнер
вне штатного deploy; гейт `Confirm Deploy` (ORCH-059) сохранён; никаких push/force-push в
`main`.
- **A-6.** Защита от взаимоблокировки: B при занятой сериализации **defer** (повторная
постановка с задержкой через `available_at`), а НЕ откат на `development` и НЕ вечное
ожидание; bounded defer-бюджет (анти-livelock, как `merge_defer_max_attempts`).
### Уровень B — Декларативные зависимости (исходный скоуп ORCH-26)
- **B-1.** Задача может объявить связь `blocked-by` / `blocks` (depends-on).
- **B-2.** Планировщик очереди (ORCH-1) **не запускает** заблокированную задачу, пока все её
depends-on не достигли терминального состояния (`done`).
- **B-3.** **Защита от дедлоков:** циклические зависимости детектируются; задача в цикле не
«пропадает молча» — выставляется `Blocked` + alert (Telegram/Plane).
- **B-4.** **Видимость:** заблокированная задача видна — Plane-статус `Blocked` и/или
ожидание в Telegram-карточке (что и кого ждёт).
---
## 4. Открытые вопросы для архитектора (НЕ решаются на этапе анализа)
> Аналитик фиксирует требования; выбор механизма — за архитектором (ADR в `06-adr/`).
1. **Где хранить связи (Уровень B):** Plane relations (родное, видимо в UI, но требует
сетевого запроса и зависит от Plane) vs таблица в БД (`job_deps`/поля `tasks`, надёжно и
offline, но дубль источника) vs **гибрид** (Plane — источник декларации, БД — кэш для
планировщика). Рекомендация анализа: гибрид с offline-fallback (см. §6).
2. **Механизм сериализации (Уровень A):** глобальный per-repo merge-lock vs FIFO merge-queue
vs **обязательный pre-merge rebase + расширение окна merge-lease** (от «момента merge» до
«main-updated»). Выбрать минимально-инвазивный, restart-safe, переиспользующий ORCH-065/043.
3. **Граница окна сериализации для self-hosting:** для не-self репо «merged в main» = конец
окна; для self (orchestrator) деплой асинхронный (Phase B/C, ORCH-036/071) — нужно решить,
до какого события держать лиз (до `merged_to_main: true` / до `done`).
4. **Совместимость B и A:** depends-on (B) на уровне постановки в очередь vs merge-сериализация
(A) на уровне merge-gate — разные точки конвейера; убедиться, что не конфликтуют.
---
## 5. Вне скоупа (Non-goals)
- Изменение машины стадий `STAGE_TRANSITIONS` (сериализация/зависимости — врезки/гейты, не
новые стадии — паттерн ORCH-043/058/071).
- Приоритизация/перепланирование задач по весам (только зависимости и сериализация).
- Кросс-репо зависимости (A-3 явно запрещает кросс-репо сериализацию; кросс-репо логические
зависимости — возможный follow-up, не v1).
- Отмена/замена рубежей ORCH-073 — ORCH-026 их **дополняет** на уровне планировщика.
---
## 6. Заинтересованные стороны
- **Owner (Слава)** — одобряет BRD; держатель self-hosting прод-риска.
- **Стрим** — автор предложения.
- **Конвейер агентов** — потребитель: developer/deployer работают с веткой, которую затрагивает
сериализация; reviewer проверяет обновление доки.
---
## 7. Критерии успеха (бизнес-уровень)
- Две зелёные задачи одного репо больше не способны затереть код друг друга в `main` на уровне
планировщика (без участия рубежей-последствий ORCH-073).
- Задача может объявить зависимость; заблокированная задача не стартует раньше времени и видна
наблюдателю.
- Пропускная способность разных репо не деградирует.
- Прод-контейнер orchestrator не падает и не рестартится вне штатного `Confirm Deploy`.
Точные PASS/FAIL — `03-acceptance-criteria.md`.

View File

@@ -0,0 +1,134 @@
# 02-ТЗ — Управление зависимостями задач (B ждёт A) в очереди
**Work Item:** ORCH-026 · **Repo:** orchestrator · **Стадия:** analysis
> ТЗ фиксирует ТРЕБОВАНИЯ к изменениям (модули, контракты, артефакты). Конкретный механизм
> сериализации и место хранения связей — решение архитектора (ADR в `06-adr/`); ниже отмечены
> как «КАНДИДАТ / решает архитектор». Аналитик не предлагает архитектуру.
---
## 1. Задействованные модули `src/`
| Модуль | Роль в задаче | Уровень |
|--------|---------------|---------|
| `src/queue_worker.py` | Планировщик: `_drain_once` / `claim_next_job` — точка учёта зависимостей и сериализации при выборе job. | A + B |
| `src/db.py` | Очередь `jobs` / `tasks`; `claim_next_job`, `enqueue_job`, `count_running_jobs`. Кандидат на хранение связей и блокировки claim. | A + B |
| `src/merge_gate.py` | merge-lease (ORCH-065), `branch_is_behind_main` / `auto_rebase_onto_main` (ORCH-043) — опора для proactive pre-merge rebase и расширения окна сериализации. | A |
| `src/qg/checks.py` | `check_branch_mergeable` (под-гейт ребра `deploy-staging → deploy`) — точка форсированного pre-merge rebase. | A |
| `src/stage_engine.py` | `advance_stage` — врезки гейтов; точка интеграции сериализации/верификации. | A |
| `src/webhooks/plane.py` | `handle_work_item_created` / `start_pipeline` — приём задачи; точка чтения relations (если источник — Plane). | B |
| `src/plane_sync.py` | `set_issue_blocked`, `get_project_states` (`blocked`/`needs_input`), relations API. | B |
| `src/notifications.py` | live-карточка: индикация `Blocked` / «ждёт ORCH-NNN». | B |
| `src/config.py` | Новые kill-switch + scope-настройки (паттерн `*_enabled` / `*_repos`). | A + B |
| `src/reconciler.py` / `src/job_reaper.py` | Не ломать: skip заблокированных задач (как уже делается для Blocked/Needs-Input, ORCH-060/068); реклейм ресурсов сериализации. | A + B |
---
## 2. Требования к изменениям — Уровень A (сериализация merge/деплоя)
### 2.1 Proactive pre-merge rebase (A-2)
- На ребре `deploy-staging → deploy`, ДО фактического merge (в составе `check_branch_mergeable`
или соседнего под-гейта), ветка задачи **всегда** догоняется на свежий `origin/main`
**не только при `branch_is_behind_main`/конфликте**.
- Переиспользовать `merge_gate.auto_rebase_onto_main` (rebase + `push --force-with-lease`
ТОЛЬКО ветки задачи). Текстовый конфликт → существующий контракт: `rebase --abort` → откат на
`development` (как ORCH-043).
- **Инвариант:** никаких push/force-push в `main`.
### 2.2 Расширение окна merge-lease (A-1, A-3, A-4)
- **КАНДИДАТ (решает архитектор):** держать per-repo merge-lease (ORCH-065) не только «на
момент merge», а на окно **«merge → main-updated»** (для self — до подтверждения
`merged_to_main: true` / `done`), чтобы B не дошла до своего merge, пока A не в `main`.
- Acquire — **неблокирующий** (как сейчас): занято → **defer** задачи B через
`enqueue_job(available_at_delay_s=...)`, bounded бюджет (анти-livelock; ср.
`merge_defer_max_attempts`). Откат на `development` НЕ применять для defer.
- Release — holder-aware (как `release_merge_lease`), на merged-вебхуке / `deploy→done` /
откате / по проактивному реклейму (ORCH-065 `reclaim_stale_lease`).
- Сериализация **строго per-repo** (`.merge-lease-<repo>.json`) — кросс-репо параллелизм не
затрагивается (A-3).
### 2.3 Условность и безопасность (A-5)
- Реально только для применимых репо: kill-switch + CSV-scope (паттерн `merge_gate_repos` /
`merge_verify_repos`; пусто → только self-hosting `orchestrator`).
- `STAGE_TRANSITIONS`, `Confirm Deploy` (ORCH-059), exit-коды deploy-хука, БАГ-8,
terminal-sync — **без изменений**.
- Контракт **never-raise** для всех новых функций (как соседи в `merge_gate.py`).
---
## 3. Требования к изменениям — Уровень B (декларативные зависимости)
### 3.1 Декларация связи (B-1)
- **КАНДИДАТ хранения (решает архитектор, см. BRD §4.1):**
- вариант Plane relations: читать `blocked-by` через Plane API в `handle_work_item_created`;
- вариант БД: новая таблица `job_deps(task_id, depends_on_task_id)` или поле в `tasks`
(idempotent `_ensure_column` миграция, как ORCH-065 `jobs.pid`);
- гибрид: Plane — декларация, БД — кэш для планировщика (offline-устойчивость).
- Миграция БД (если выбран вариант с таблицей/колонкой) — **только аддитивная**
(`CREATE TABLE IF NOT EXISTS` / `_ensure_column`), безопасная на живой прод-БД с общими
данными enduro-trails.
### 3.2 Гейт планировщика (B-2)
- При выборе job (`claim_next_job` / `_drain_once`) задача с незавершёнными depends-on
**не клеймится** (аналог `available_at`-gate): пропускается до тех пор, пока все depends-on
не `done`. Не должна занимать слот `max_concurrency`.
- Реализация — **leaf-функция** с чистой логикой «готова ли задача к запуску» (тестируемо
юнитами, never-raise), по образцу `staging_verdict.py` / `post_deploy.py`.
### 3.3 Защита от дедлоков (B-3)
- Детектор циклов в графе depends-on (DFS/обнаружение цикла) — чистая функция, юнит-тестируемая.
- Цикл → задача(и) НЕ запускается молча: `set_issue_blocked` + alert (Telegram/Plane) с
указанием цикла. Не блокировать поток других задач.
### 3.4 Видимость (B-4)
- Заблокированная задача: Plane-статус `Blocked` (`set_issue_blocked`) и/или строка ожидания в
Telegram-карточке («⏳ ждёт ORCH-NNN»). Использовать существующий механизм карточки
(`notifications.update_task_tracker`), контракт never-raise / silent.
- `reconciler` F-1 уже пропускает Blocked/Needs-Input (ORCH-060/068) — убедиться, что новые
заблокированные-по-зависимости задачи тоже пропускаются (не «разблокируются» ошибочно).
---
## 4. Изменения API (endpoints)
- **Новые HTTP endpoints не требуются.**
- **Наблюдаемость:** расширить снимок `GET /queue` блоком о зависимостях/сериализации
(по образцу блоков `reconcile` / `reaper` / `post_deploy` / `merge_verify`): кол-во
заблокированных задач, держатель merge-lease, defer-счётчики, обнаруженные циклы. Read-only,
никогда не источник истины для решений.
## 5. Изменения схемы БД
- **КАНДИДАТ (если выбран БД/гибрид для Уровня B):** аддитивная таблица `job_deps` или колонка
в `tasks` (см. §3.1). Только `CREATE TABLE IF NOT EXISTS` / `_ensure_column`. Без изменения
существующих колонок `jobs`/`tasks`. Restart-safe, безопасно на общей прод-БД.
- Уровень A (сериализация) — **без изменения схемы БД** (merge-lease файловый, как ORCH-065).
## 6. Требования к новым QG checks
- **Новый зарегистрированный QG-чек НЕ вводится** (паттерн ORCH-071/058: под-гейт — врезка в
`advance_stage` или расширение `check_branch_mergeable`, а не новая запись в `QG_CHECKS`).
- Реестр `QG_CHECKS` — без изменений.
## 7. Конфигурация (`src/config.py`)
Новые настройки по паттерну `*_enabled` (kill-switch) + `*_repos` (CSV scope, пусто →
self-hosting). КАНДИДАТ-имена (финализирует архитектор):
- Уровень A: `merge_serialize_enabled` / `merge_serialize_repos` (или расширение
`merge_gate_*`); опционально `premerge_rebase_always` (вкл proactive rebase).
- Уровень B: `task_deps_enabled` / `task_deps_source` (`plane|db|hybrid`).
Дефолты — обратная совместимость (для не-self репо — прежнее поведение).
## 8. Артефакты pipeline (создать/обновить В ТОМ ЖЕ PR)
- `06-adr/ADR-001-*.md` — решение по сериализации (A) и хранению зависимостей (B).
- Обновить `docs/architecture/README.md` (раздел про очередь/merge-gate/сериализацию).
- Обновить `CLAUDE.md` (паспорт: конвейер/инварианты, если меняется поведение очереди).
- Обновить `CHANGELOG.md` (`## [Unreleased]`).
- Если вводится таблица БД — отразить в `08-data-requirements.md` (создаёт архитектор).
- `07-infra-requirements.md` — если требуется новый Plane-статус/настройка relations.
## 9. Инварианты (НЕ нарушать)
1. `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, `check_deploy_status`/`check_staging_status`,
`Confirm Deploy` (ORCH-059), БАГ-8, terminal-sync — без изменений.
2. Никаких push/force-push в `main`; force только `--force-with-lease` на ветку задачи.
3. Сериализация — строго per-repo; кросс-репо параллелизм сохранён.
4. never-raise во всех новых функциях; restart-safe состояние.
5. ORCH-026 дополняет рубежи ORCH-073, не заменяет.
6. Прод-контейнер orchestrator не рестартится вне штатного `Confirm Deploy`.

View File

@@ -0,0 +1,107 @@
# 03-Критерии приёмки — ORCH-026
**Work Item:** ORCH-026 · **Repo:** orchestrator · **Стадия:** analysis
Каждый критерий — проверяемое условие PASS/FAIL. Маппинг на тесты — `04-test-plan.yaml`.
---
## Уровень A — Сериализация merge/деплоя внутри одного репо
### AC-A1 — Сериализация merge внутри репо
- **PASS:** пока задача A применимого репо удерживает окно merge (merge-lease не освобождён /
`main` ещё не обновлён), задача B того же репо НЕ доходит до фактического merge — она
**defer**-ится (повторная постановка через `available_at`), а не мержится от устаревшего `main`.
- **FAIL:** B мержится/деплоится, пока A не в `main`; или B откатывается на `development` вместо
defer.
### AC-A2 — Proactive pre-merge rebase
- **PASS:** перед merge ветка задачи **всегда** догоняется на свежий `origin/main` (вызывается
rebase), даже когда текстового конфликта нет и ветка формально не «behind» по старой проверке;
после rebase ветка содержит код предшественника (A).
- **FAIL:** rebase запускается только при конфликте/`branch_is_behind_main`, и B мержится без
кода A.
### AC-A3 — Кросс-репо параллелизм сохранён
- **PASS:** задача в `orchestrator` и задача в `enduro-trails` доходят до merge/деплоя
параллельно — сериализация одного репо не блокирует другой (lease/гейт строго per-repo).
- **FAIL:** задача одного репо ждёт освобождения ресурса, удерживаемого задачей ДРУГОГО репо.
### AC-A4 — Restart-safe
- **PASS:** после рестарта прод-контейнера состояние сериализации восстанавливается корректно;
мёртвый держатель merge-lease проактивно реклеймится (ORCH-065), конвейер не встаёт навсегда.
- **FAIL:** рестарт оставляет навсегда захваченный lease → конвейер всех проектов встаёт.
### AC-A5 — Self-hosting safety
- **PASS:** прод-контейнер orchestrator НЕ рестартится/не падает вне штатного `Confirm Deploy`
(ORCH-059); нет push/force-push в `main`; `STAGE_TRANSITIONS` и реестр `QG_CHECKS` не изменены.
- **FAIL:** любой незапрошенный рестарт прода, прямой push в `main`, или изменение машины стадий.
### AC-A6 — Anti-deadlock / anti-livelock при defer
- **PASS:** при занятой сериализации B defer-ится с задержкой и bounded бюджетом; исчерпание
бюджета → эскалация (alert/Blocked), не бесконечный цикл и не откат.
- **FAIL:** B уходит в вечный defer-цикл, либо немедленно откатывается на `development`.
### AC-A7 — Условность (не-self репо без регресса)
- **PASS:** при выключенном kill-switch и для репо вне scope поведение конвейера 1:1 как до
ORCH-026 (нулевая регрессия для enduro-trails).
- **FAIL:** не-self репо меняет поведение merge/деплоя.
---
## Уровень B — Декларативные зависимости
### AC-B1 — Декларация зависимости
- **PASS:** задача может объявить `blocked-by`/`depends-on` (через выбранный источник —
Plane relations / БД / гибрid), и связь корректно считывается планировщиком.
- **FAIL:** связь не считывается / теряется.
### AC-B2 — Гейт планировщика (B не стартует до A)
- **PASS:** задача с незавершённым depends-on **не клеймится** воркером (не запускается агент,
слот `max_concurrency` не занимается), пока все depends-on не достигли `done`; как только A
становится `done` — B становится claimable.
- **FAIL:** B запускается раньше завершения A; или занимает слот, простаивая.
### AC-B3 — Детект дедлоков (циклы)
- **PASS:** циклическая зависимость (A→B→A и длиннее) детектируется детерминированно; задача(и)
в цикле → `Blocked` + alert (Telegram/Plane) с указанием цикла; поток остальных задач не
блокируется.
- **FAIL:** цикл приводит к молчаливому вечному ожиданию или к падению воркера.
### AC-B4 — Видимость заблокированной задачи
- **PASS:** заблокированная задача видна — Plane-статус `Blocked` и/или строка ожидания в
Telegram-карточке (что/кого ждёт); инвариант «одна карточка на задачу» сохранён.
- **FAIL:** заблокированная задача невидима наблюдателю.
### AC-B5 — Совместимость с reconciler/reaper
- **PASS:** `reconciler` F-1 НЕ «разблокирует» задачу, заблокированную по зависимости (как уже
делает для Blocked/Needs-Input, ORCH-060/068); reaper не реапит корректно ожидающую задачу.
- **FAIL:** reconciler продвигает заблокированную задачу мимо её depends-on.
---
## Общие (оба уровня)
### AC-G1 — never-raise
- **PASS:** любая ошибка (git/сеть/БД/Plane) в новой логике не пробрасывается в `advance_stage`/
воркер; деградирует консервативно (defer/skip/fail-closed), конвейер не падает.
- **FAIL:** необработанное исключение роняет воркер/монитор-поток.
### AC-G2 — Kill-switch
- **PASS:** глобальный kill-switch выключает фичу целиком → поведение 1:1 как до ORCH-026.
- **FAIL:** при выключенном флаге поведение изменено.
### AC-G3 — Документация обновлена (golden source)
- **PASS:** в ТОМ ЖЕ PR обновлены `docs/architecture/README.md`, `CLAUDE.md` (если изменилось
поведение очереди), `CHANGELOG.md`, заведён ADR в `06-adr/`. Reviewer проверяет.
- **FAIL:** код изменён, документация — нет (→ REQUEST_CHANGES).
### AC-G4 — Миграция БД безопасна (если применимо)
- **PASS:** миграция только аддитивная (`CREATE TABLE IF NOT EXISTS`/`_ensure_column`),
идемпотентна, безопасна на живой общей прод-БД; существующие данные enduro-trails не затронуты.
- **FAIL:** деструктивная миграция / изменение существующих колонок.
### AC-G5 — Тесты зелёные
- **PASS:** новые unit+integration тесты (`04-test-plan.yaml`) проходят; существующий
`pytest tests/ -q` остаётся зелёным (нет регресса merge-gate/merge-verify/reconciler/reaper).
- **FAIL:** красный pytest или регресс существующих тестов.

View File

@@ -0,0 +1,169 @@
work_item: ORCH-026
description: >
План тестов для управления зависимостями задач (Уровень B) и сериализации
merge/деплоя внутри одного репо (Уровень A). Стек: pytest. Имена модулей/функций —
кандидаты; финализирует архитектор/разработчик. Все новые функции — never-raise.
tests:
# ---------------- Уровень A: сериализация merge/деплоя ----------------
- id: TC-A01
type: unit
description: >
Proactive pre-merge rebase: ветка догоняется на свежий origin/main ДАЖЕ когда
branch_is_behind_main вернул бы False (нет конфликта). Проверить, что rebase
вызывается всегда перед merge (AC-A2).
module: tests/test_orch026_premerge_rebase.py
expected: PASS
- id: TC-A02
type: unit
description: >
Расширенное окно merge-lease: пока A держит lease (окно merge→main-updated),
acquire для B того же репо возвращает busy → defer (не откат). holder-aware
release не удаляет чужой lease (AC-A1, AC-A6).
module: tests/test_orch026_merge_serialize.py
expected: PASS
- id: TC-A03
type: unit
description: >
Сериализация строго per-repo: lease/гейт orchestrator не влияет на задачу
enduro-trails — обе claimable параллельно (AC-A3).
module: tests/test_orch026_merge_serialize.py
expected: PASS
- id: TC-A04
type: unit
description: >
Restart-safe + проактивный реклейм: мёртвый держатель lease (pid не жив)
реклеймится reclaim_stale_lease; конвейер не встаёт навсегда (AC-A4).
module: tests/test_orch026_merge_serialize.py
expected: PASS
- id: TC-A05
type: unit
description: >
Anti-livelock defer: B defer-ится с available_at-задержкой и bounded бюджетом;
исчерпание → эскалация (Blocked/alert), не бесконечный цикл (AC-A6).
module: tests/test_orch026_merge_serialize.py
expected: PASS
- id: TC-A06
type: unit
description: >
Условность/kill-switch: при выключенном флаге и для репо вне scope поведение
merge/деплоя 1:1 как до ORCH-026 — no-op (AC-A7, AC-G2).
module: tests/test_orch026_conditionality.py
expected: PASS
- id: TC-A07
type: unit
description: >
Self-hosting safety: новая логика никогда не делает push/force-push в main;
force только --force-with-lease на ветку задачи; STAGE_TRANSITIONS не изменены
(AC-A5).
module: tests/test_orch026_conditionality.py
expected: PASS
- id: TC-A08
type: integration
description: >
Сквозной сценарий: две задачи одного репо проходят deploy-staging→deploy; B не
доходит до merge, пока A не в main; после A→done B ребейзится на свежий main
(несёт код A) и мержится. main не теряет код A (AC-A1/AC-A2).
module: tests/test_orch026_serialize_integration.py
expected: PASS
# ---------------- Уровень B: декларативные зависимости ----------------
- id: TC-B01
type: unit
description: >
Чтение/декларация связи blocked-by из выбранного источника (Plane/БД/гибрид);
связь корректно резолвится в depends_on_task_id (AC-B1). never-raise при
недоступности источника → консервативно (нет связи или fail-closed по решению ADR).
module: tests/test_orch026_task_deps.py
expected: PASS
- id: TC-B02
type: unit
description: >
Гейт готовности (leaf-функция): задача с незавершённым depends-on НЕ ready;
все depends-on в done → ready. Чистая логика, юнит-тестируемая (AC-B2).
module: tests/test_orch026_task_deps.py
expected: PASS
- id: TC-B03
type: unit
description: >
Детект циклов: A→B→A (и длиннее) детектируется детерминированно; ацикличный
граф → циклов нет. Чистая функция (AC-B3).
module: tests/test_orch026_dep_cycles.py
expected: PASS
- id: TC-B04
type: unit
description: >
Цикл → set_issue_blocked + alert (Telegram/Plane), без падения воркера и без
блокировки потока других задач (AC-B3, AC-G1).
module: tests/test_orch026_dep_cycles.py
expected: PASS
- id: TC-B05
type: unit
description: >
claim_next_job не клеймит заблокированную задачу (не занимает слот
max_concurrency); как только depends-on done — задача становится claimable (AC-B2).
module: tests/test_orch026_task_deps.py
expected: PASS
- id: TC-B06
type: unit
description: >
Видимость: заблокированная задача отражается в Plane-статусе Blocked и/или
строке ожидания Telegram-карточки; инвариант «одна карточка на задачу» сохранён
(AC-B4). notifications never-raise / silent.
module: tests/test_orch026_dep_visibility.py
expected: PASS
- id: TC-B07
type: unit
description: >
reconciler F-1 НЕ разблокирует задачу, заблокированную по зависимости (как для
Blocked/Needs-Input); reaper не реапит корректно ожидающую (AC-B5).
module: tests/test_orch026_task_deps.py
expected: PASS
- id: TC-B08
type: integration
description: >
Сквозной сценарий: B объявлена blocked-by A; при постановке в очередь B не
стартует, пока A не done; после A→done воркер запускает B. Telegram/Plane
показывают Blocked у B до разблокировки (AC-B1/B2/B4).
module: tests/test_orch026_deps_integration.py
expected: PASS
# ---------------- Общие / миграция / регресс ----------------
- id: TC-G01
type: unit
description: >
Аддитивная миграция БД (если выбран вариант с таблицей/колонкой): идемпотентна,
безопасна на существующей БД с данными, не меняет существующие колонки (AC-G4).
module: tests/test_orch026_migration.py
expected: PASS
- id: TC-G02
type: unit
description: >
Наблюдаемость GET /queue: новый блок (заблокированные задачи / держатель lease /
defer-счётчики / циклы) присутствует и read-only; не источник истины.
module: tests/test_orch026_queue_observability.py
expected: PASS
- id: TC-G03
type: integration
description: >
Регресс: полный pytest tests/ -q остаётся зелёным — merge-gate (ORCH-043),
merge-verify (ORCH-073), reconciler (ORCH-053/068), reaper (ORCH-065) не
деградировали (AC-G5).
module: tests/
expected: PASS

View File

@@ -0,0 +1,226 @@
# ADR-001: Сериализация merge/деплоя внутри репо (A) + декларативные зависимости задач (B)
**Work Item:** ORCH-026 · **Repo:** orchestrator (self-hosting) · **Стадия:** architecture
**Статус:** Accepted
**Связи:** дополняет ORCH-043 (merge-gate), ORCH-065 (merge-lease + reclaim), ORCH-073/071
(merge-verify, SHA-in-main), ORCH-1 (очередь). Глобальный ADR — `adr/adr-0015`.
---
## Контекст
ORCH-026 закрывает **первопричину** эрозии `main` 08.06 (некоординированный параллелизм
задач одного репо: ветки от устаревшего `main`, фантом-merge затирает соседа) и попутно вводит
исходный скоуп — декларативные зависимости задач (B ждёт A). Требования — `01-brd.md`,
`02-trz.md`; PASS/FAIL — `03-acceptance-criteria.md`.
Ключевое наблюдение архитектора: **бо́льшая часть инфраструктуры для Уровня A уже существует** и
её НЕ нужно переписывать:
- **merge-lease** (ORCH-065, `src/merge_gate.py`): per-repo файловый лиз
`.merge-lease-<repo>.json`, неблокирующий acquire, holder-aware release, проактивный реклейм
мёртвого/устаревшего держателя (`reclaim_stale_lease`, `pid_alive`). Restart-safe, per-repo.
- **merge-gate** (ORCH-043, `check_branch_mergeable`): на ребре `deploy-staging → deploy`
захватывает лиз, при необходимости ребейзит, держит лиз до фактического merge.
- **defer-механизм** (`_handle_merge_gate_defer`): `merge-lock busy` → повторная постановка
deployer'а через `available_at`, bounded `merge_defer_max_attempts` → эскалация (Blocked+alert).
- **окно лиза** уже простирается от `deploy-staging → deploy` до release на одном из событий:
PR-merged webhook (`gitea.py`), `deploy→done` (`stage_engine.py`), откат, проактивный реклейм.
Для self-hosting `done` достигается ТОЛЬКО после `verify_merged_to_main` (SHA-in-main, ORCH-073).
Таким образом окно сериализации A-1 («merge → main-updated») **структурно уже реализовано**:
пока A не подтверждена в `main` (для self — SHA-in-main → `done`), лиз держится, и B того же
репо на своём merge-gate получает `merge-lock busy` → defer. Открытый вопрос BRD §4.3 (граница
окна для self) решается так: **окно = от acquire до release; release-события не меняем**. Для
non-self репо граница — PR-merged webhook; для self — `deploy→done` (= SHA-in-main подтверждён).
Что реально **отсутствует** для Уровня A:
- **A-2: безусловный proactive pre-merge rebase.** Сейчас `check_branch_mergeable` ребейзит
ТОЛЬКО если `branch_is_behind_main` (⇔ `origin/main` не предок HEAD). AC-A2 требует, чтобы
rebase вызывался **всегда** перед merge — детерминированный структурный анти-фантом на уровне
планировщика, не зависящий от точности ancestor-проверки.
Для Уровня B инфраструктуры нет вовсе: очередь `jobs` (ORCH-1) плоская (FIFO по `id` +
`available_at` + `max_concurrency`), выразить «B ждёт A» нельзя.
---
## Решение
### Уровень A — сериализация merge/деплоя (минимально-инвазивно, переиспользуя ORCH-043/065)
**A-1/A-3/A-4 (окно сериализации) — без изменений механизма.** Окно сериализации обеспечивается
существующим merge-lease: захват в `check_branch_mergeable`, удержание до release. Подтверждаем и
фиксируем в доке, что release-события (`PR-merged` / `deploy→done` / откат / `reclaim_stale_lease`)
формируют окно «merge → main-updated». Кросс-репо параллелизм сохранён автоматически (лиз —
per-repo файл). Restart-safe и анти-залипание — за счёт ORCH-065 reclaim. **Кода-изменений нет.**
**A-2 (безусловный pre-merge rebase) — новое поведение, флаг `premerge_rebase_always`.**
- В `check_branch_mergeable` (`src/qg/checks.py`), ПОД захваченным merge-lease: когда
`settings.premerge_rebase_always` истинно (и merge-gate применим к репо), **пропустить
short-circuit `branch_is_behind_main`** и **всегда** вызвать `merge_gate.auto_rebase_onto_main`.
- `auto_rebase_onto_main` уже идемпотентен и дёшев на актуальной ветке: `git rebase origin/main`
на не-отстающей ветке — no-op (rc 0, HEAD не меняется), последующий `push --force-with-lease`
→ «Everything up-to-date» (тот же SHA, **CI не перезапускается, лишних коммитов нет**). На
отстающей ветке — реальный догон. Текстовый конфликт → существующий контракт: `rebase --abort`
→ откат на `development` (как ORCH-043). **Инвариант: никаких push/force-push в `main`**
единственная force-операция остаётся `--force-with-lease` на ветку задачи.
- Когда флаг выключен → прежнее поведение (ребейз только при `branch_is_behind_main`),
обратная совместимость 1:1 (AC-A7/AC-G2).
- **Скоуп — общий с merge-gate:** реально только для `merge_gate_repos` (пусто → self-hosting
`orchestrator`). Никакого нового scope-флага.
**A-5/A-6 (safety, anti-livelock) — без изменений.** `STAGE_TRANSITIONS`, `QG_CHECKS`,
`Confirm Deploy` (ORCH-059), exit-коды хука, terminal-sync не трогаются. defer-бюджет —
существующий `merge_defer_max_attempts` → Blocked+alert при исчерпании. Прод-контейнер не
рестартится вне штатного `Confirm Deploy`.
### Уровень B — декларативные зависимости (новая инфраструктура)
**B-источник: гибрид с БД как источником истины для планировщика; флаг `task_deps_source`.**
Планировщик `claim_next_job` — горячий цикл, обслуживающий очередь ВСЕХ проектов из ОДНОГО
инстанса. Он **обязан** быть offline-устойчивым и быстрым: сетевой запрос в Plane на каждый claim
= при недоступности Plane встанет конвейер всех проектов (нарушение self-hosting safety). Поэтому:
- **Авторитетный для планировщика стор — локальная БД**, новая аддитивная таблица
`job_deps(task_id, depends_on_task_id, created_at)` (детали — `08-data-requirements.md`).
Связь хранится по `tasks.id` (стабильный локальный ключ). Зависимости — **только внутри одного
репо** (v1; кросс-репо — non-goal, BRD §5).
- **`task_deps_source = db | plane | hybrid`** (дефолт **`db`**): `db` — связи пишутся напрямую в
`job_deps` (потребитель — декомпозиция эпиков ORCH-025); `plane` — связи читаются из Plane
relations в `handle_work_item_created` и **кэшируются** в `job_deps`; `hybrid` — Plane как
декларация + БД-кэш. Plane-ingestion — тонкий add-on за флагом; планировщик ВСЕГДА читает БД.
**B-2 (гейт планировщика) — SQL `NOT EXISTS`, без занятия слота `max_concurrency`.**
Гейт готовности выражается декларативно в `claim_next_job` (`src/db.py`): задача claimable, если
у неё нет ни одной незавершённой зависимости. Когда `settings.task_deps_enabled` — к существующему
SELECT добавляется условие:
```sql
AND NOT EXISTS (
SELECT 1 FROM job_deps d
JOIN tasks t ON t.id = d.depends_on_task_id
WHERE d.task_id = j.task_id AND t.stage != 'done'
)
```
Это: (1) **не занимает слот** — job просто не выбирается, агент не запускается (AC-B2);
(2) restart-safe (чистая БД); (3) never-raise (это SQL); (4) при пустой `job_deps`
инертно (нулевая регрессия, AC-G2); (5) при выключенном `task_deps_enabled` условие НЕ
добавляется → запрос 1:1 как в ORCH-1. Как только все зависимости достигают `stage='done'`,
задача автоматически становится claimable.
Чистая leaf-логика «готова ли задача» выносится в новый модуль `src/task_deps.py`:
`is_task_ready(task_id) -> (bool, waiting_on: list[str])` (never-raise) — для реконсилятора,
карточки и `/queue` (SQL в `claim_next_job` — горячий путь, дублирует ту же семантику).
**B-3 (детект дедлоков) — DFS, чистая функция.**
`task_deps.detect_cycle(task_id) -> list[int] | None` — обход графа `job_deps` (внутри репо),
детерминированный, юнит-тестируемый, never-raise. Запускается: (1) при вставке связи
(`add_dependency`) — цикл отклоняется/алертится сразу (лучший UX); (2) backstop-проход в тике
`reconciler` (на случай связей, добавленных в обход). Цикл → `set_issue_blocked(work_item_id)` +
Telegram/Plane alert с перечислением цикла. SQL-гейт B-2 сам по себе никогда не выберет задачу в
цикле (её зависимости не достигнут `done`) — детектор делает это **видимым**, а не молчаливым
вечным ожиданием (AC-B3). Поток остальных задач не блокируется.
**B-4 (видимость).**
- Нормальное ожидание (B ждёт A, A в работе — транзиентно и ожидаемо): строка в Telegram-карточке
«⏳ ждёт ORCH-NNN» через `notifications.update_task_tracker`, never-raise/silent. **Plane Blocked
при нормальном ожидании НЕ ставим** — иначе флаппинг Blocked на каждом коротком ожидании.
- Дедлок/цикл (B-3): `set_issue_blocked` (Plane `Blocked`) + alert. Это «и/или» из AC-B4.
- Инвариант «одна карточка на задачу» сохранён (ORCH-042/067).
**B-5 (совместимость reconciler/reaper).**
- `reconciler` F-1 не должен «разблокировать» dep-заблокированную задачу мимо её зависимостей.
В фильтр пригодности reconciler добавляется проверка `task_deps.is_task_ready` (по образцу
`reconcile_skip_blocked_enabled`, ORCH-060): не готова → skip.
- `reaper` сканирует **`running`** jobs; dep-заблокированный job остаётся `queued` (его не
клеймят) → reaper его не трогает по построению. Фиксируем в доке.
**Наблюдаемость (TRZ §4):** блок `task_deps` в снимке `GET /queue` (read-only, по образцу
`reconcile`/`reaper`): кол-во заблокированных задач, держатель merge-lease, defer-счётчики,
обнаруженные циклы. Никогда не источник решений.
### Конфигурация (`src/config.py`)
| Флаг | Дефолт | Назначение |
|------|--------|-----------|
| `premerge_rebase_always` | `True` | Уровень A: безусловный pre-merge rebase под лизом. Скоуп — `merge_gate_repos`. Kill-switch (`False` → ребейз только при behind, как ORCH-043). |
| `task_deps_enabled` | `True` | Уровень B: глобальный kill-switch гейта зависимостей. `False``claim_next_job` 1:1 как ORCH-1. Инертно при пустой `job_deps`. |
| `task_deps_source` | `"db"` | Источник деклараций: `db`\|`plane`\|`hybrid`. Планировщик всегда читает БД-кэш. |
Дефолты следуют конвенции репо (`*_enabled=True` + kill-switch), при этом обе фичи инертны без
данных (нет деклараций / нет применимых репо) → нулевая регрессия для enduro-trails.
---
## Альтернативы (и почему отвергнуты)
1. **Уровень A — отдельный глобальный per-repo merge-lock или FIFO merge-queue.** Дублировал бы
уже существующий merge-lease (ORCH-065), вводил второй механизм сериализации с риском
рассинхрона. Отвергнуто: BRD §4.2 требует минимально-инвазивного решения, переиспользующего
ORCH-065/043. Окно лиза уже даёт сериализацию.
2. **Уровень A — расширять release-точки лиза (держать до отдельного `main-updated`-события).**
Не требуется: для self `done` ⇔ SHA-in-main (ORCH-073), для non-self — PR-merged webhook;
окно уже корректно. Доп. событие усложнило бы reclaim без выигрыша.
3. **Уровень B — Plane relations как источник истины планировщика.** Сетевой запрос в горячем
цикле claim; при недоступности Plane встаёт очередь всех проектов (self-hosting risk).
Отвергнуто; Plane оставлен опциональным источником **декларации** (`task_deps_source=plane`),
но планировщик читает только БД-кэш.
4. **Уровень B — гейт зависимостей в воркере (`_drain_once`) поверх `claim_next_job`.** Пришлось
бы клеймить job, обнаруживать незавершённую зависимость и re-queueить — churn, расход attempts,
гонки. SQL `NOT EXISTS` в самом `claim_next_job` чище: job просто не выбирается, слот свободен.
5. **Уровень B — поле/JSON в `tasks` вместо таблицы.** Таблица `job_deps` нормальна (M:N),
индексируема, проще для DFS и `NOT EXISTS`. Поле в `tasks` потребовало бы парсинг-логики.
---
## Последствия
**Плюсы.**
- Минимально-инвазивно: Уровень A — один флаг + снятие short-circuit; окно сериализации не
переписывается. Переиспользует ORCH-043/065 целиком.
- Уровень B — одно `NOT EXISTS` в `claim_next_job` + аддитивная таблица + leaf-модуль
`task_deps.py`; `STAGE_TRANSITIONS`/`QG_CHECKS` не тронуты (паттерн врезки ORCH-071/058).
- Обе фичи инертны без данных → нулевая регрессия для enduro-trails (AC-A7/AC-G2).
- restart-safe (БД + файловый лиз), never-raise, kill-switch на каждую фичу.
**Минусы / ограничения.**
- `premerge_rebase_always=True` добавляет (дешёвый, no-op на актуальной ветке) `rebase`+`push`
на каждый self-merge. Цена — лишний git-вызов; компенсируется детерминизмом анти-фантома.
- Уровень B v1 — только intra-repo зависимости; кросс-репо — follow-up (non-goal).
- Гейт B-2 в `claim_next_job` слегка усложняет горячий SQL (один `NOT EXISTS`); защищён
kill-switch и инертностью при пустой таблице.
- `task_deps.py` цикл-детектор — новая поверхность; покрывается юнит-тестами (`04-test-plan.yaml`).
**Инварианты (не нарушать).**
1. `STAGE_TRANSITIONS`, `QG_CHECKS`, `check_deploy_status`/`check_staging_status`,
`Confirm Deploy` (ORCH-059), БАГ-8, terminal-sync — без изменений.
2. Никаких push/force-push в `main`; force только `--force-with-lease` на ветку задачи.
3. Сериализация — строго per-repo; кросс-репо параллелизм сохранён.
4. never-raise во всех новых функциях; restart-safe состояние; миграция БД только аддитивная.
5. ORCH-026 **дополняет** рубежи ORCH-073, не заменяет.
6. Прод-контейнер orchestrator не рестартится вне штатного `Confirm Deploy`.
**Места реализации (для developer).**
- `src/qg/checks.py::check_branch_mergeable` — ветка `premerge_rebase_always`.
- `src/db.py::claim_next_job` — условный `NOT EXISTS`-гейт; новые helpers `add_dependency`,
`get_dependencies`, `job_deps` миграция в `init_db` (`CREATE TABLE IF NOT EXISTS`).
- `src/task_deps.py` (новый, leaf) — `is_task_ready`, `detect_cycle`, snapshot для `/queue`.
- `src/webhooks/plane.py::handle_work_item_created` — ingestion Plane relations (за `task_deps_source`).
- `src/reconciler.py` — skip dep-заблокированных + backstop цикл-детект.
- `src/notifications.py` — строка ожидания в карточке.
- `src/config.py``premerge_rebase_always`, `task_deps_enabled`, `task_deps_source`.
- Документация: `docs/architecture/README.md`, `CLAUDE.md` (если меняется поведение очереди),
`CHANGELOG.md`, глобальный `adr/adr-0015`.

View File

@@ -0,0 +1,65 @@
# 08 — Требования к схеме БД — ORCH-026
**Work Item:** ORCH-026 · **Repo:** orchestrator · **Стадия:** architecture
**Связь:** ADR `06-adr/ADR-001-merge-serialization-and-task-deps.md` (Уровень B).
> Уровень A (сериализация merge/деплоя) — **БЕЗ изменения схемы БД** (merge-lease файловый,
> `.merge-lease-<repo>.json`, ORCH-065). Изменения схемы касаются ТОЛЬКО Уровня B.
---
## Новая таблица `job_deps` (аддитивная)
Хранит декларативные зависимости «задача `task_id` ждёт задачу `depends_on_task_id`».
```sql
CREATE TABLE IF NOT EXISTS job_deps (
task_id INTEGER NOT NULL, -- tasks.id зависимой задачи (B)
depends_on_task_id INTEGER NOT NULL, -- tasks.id задачи-предшественника (A)
created_at TEXT DEFAULT (datetime('now')),
PRIMARY KEY (task_id, depends_on_task_id)
);
CREATE INDEX IF NOT EXISTS idx_job_deps_task ON job_deps(task_id);
CREATE INDEX IF NOT EXISTS idx_job_deps_depends ON job_deps(depends_on_task_id);
```
### Поля
| Поле | Тип | Назначение |
|------|-----|-----------|
| `task_id` | INTEGER | `tasks.id` зависимой задачи (B). Не запускается, пока зависимости не `done`. |
| `depends_on_task_id` | INTEGER | `tasks.id` предшественника (A). Терминальность — `tasks.stage = 'done'`. |
| `created_at` | TEXT | Время декларации (диагностика). |
### Ключ и индексы
- **PK `(task_id, depends_on_task_id)`** — идемпотентность вставки (повторная декларация связи —
no-op через `INSERT OR IGNORE`), запрет дублей.
- `idx_job_deps_task` — гейт планировщика (`NOT EXISTS ... WHERE d.task_id = j.task_id`).
- `idx_job_deps_depends` — обратные рёбра для DFS цикл-детектора.
### Семантика готовности (источник истины планировщика)
Задача `task_id` **готова к запуску** ⇔ нет ни одной строки `job_deps` для неё, чей
`depends_on_task_id` указывает на задачу с `tasks.stage != 'done'`. Терминал — только `done`
(совпадает с тем, как `get_active_tasks_for_reconcile` трактует терминальность).
### Связь по `task_id`, а не `work_item_id`
`tasks.id` — стабильный локальный автоинкремент-ключ; `work_item_id`/`plane_id` могут
ресолвиться/коллизиться (см. `ensure_unique_work_item_id`). FK логический (без `REFERENCES`,
как у `jobs.task_id`) — не блокирует аддитивную миграцию и удаление строк tasks (которого в
конвейере нет). Зависимости — **только intra-repo** (v1); кросс-репо рёбра не создаются.
---
## Миграция (AC-G4)
- Выполняется в `src/db.py::init_db` рядом с прочими: **только** `CREATE TABLE IF NOT EXISTS` +
`CREATE INDEX IF NOT EXISTS`. **Идемпотентно**, restart-safe, безопасно на живой общей прод-БД.
- **Существующие колонки/таблицы (`jobs`, `tasks`, `agent_runs`, `events`) НЕ изменяются** →
данные enduro-trails не затронуты.
- Откат фичи — флагом `task_deps_enabled=False` (таблица остаётся, гейт не применяется); сама
таблица деструктивно не удаляется.
## Что НЕ меняется
- Схема `jobs` (включая `available_at`, `pid`, `attempts`/`transient_attempts`) — без изменений;
defer Уровня A/B переиспользует существующий `available_at`-механизм.
- Схема `tasks` — без изменений (видимость через существующие `tracker_message_id` и Plane Blocked).
- merge-lease — файловый, вне БД.

View File

@@ -0,0 +1,17 @@
# 10 — Технические риски — ORCH-026
**Work Item:** ORCH-026 · **Repo:** orchestrator · **Стадия:** architecture
**Связь:** ADR `06-adr/ADR-001-merge-serialization-and-task-deps.md`.
| # | Риск | Уровень | Митигация |
|---|------|---------|-----------|
| R-1 | **Гейт `NOT EXISTS` в `claim_next_job` (горячий путь всех проектов) содержит баг → встаёт очередь ВСЕХ проектов** (self-hosting групповой риск). | Высокий | Условие добавляется ТОЛЬКО при `task_deps_enabled`; инертно при пустой `job_deps` (нулевая регрессия); kill-switch `task_deps_enabled=False` мгновенно возвращает поведение ORCH-1; интеграционный тест «пустые deps ⇒ FIFO 1:1» (AC-G2). |
| R-2 | **Безусловный `premerge_rebase_always` делает лишний `push --force-with-lease` → ложный перезапуск CI / новые коммиты.** | Низкий | На актуальной ветке `rebase origin/main` — no-op (HEAD не меняется), push → «Everything up-to-date» (тот же SHA, CI не триггерится). Подтвердить тестом, что SHA не меняется на уже-актуальной ветке. |
| R-3 | **Дедлок по циклической зависимости → задача молча ждёт вечно.** | Средний | DFS-детектор `detect_cycle` при вставке связи + backstop в `reconciler`; цикл → `set_issue_blocked` + alert с перечислением цикла (AC-B3); SQL-гейт не выбирает задачу в цикле, детектор делает это видимым. |
| R-4 | **Livelock: B бесконечно deferится на `merge-lock busy`.** | Низкий | Существующий bounded-бюджет `merge_defer_max_attempts` → Blocked+alert (ORCH-043, без изменений). |
| R-5 | **Залипший merge-lease после смерти держателя → конвейер репо встаёт навсегда.** | Средний | Переиспользуется ORCH-065: `reclaim_stale_lease` (мёртвый `pid` / TTL `merge_lock_timeout_s`) + holder-aware release. Restart-safe (AC-A4). |
| R-6 | **Plane relations недоступны/неверно смаплены при `task_deps_source=plane`.** | Средний | Планировщик читает ТОЛЬКО БД-кэш `job_deps`; Plane-ingestion — best-effort, never-raise; дефолт `task_deps_source=db` не зависит от Plane. |
| R-7 | **reconciler «разблокирует» dep-заблокированную задачу мимо её зависимостей.** | Средний | В фильтр reconciler добавляется `is_task_ready` (паттерн ORCH-060 skip-Blocked); reaper трогает только `running` — dep-блок остаётся `queued` (AC-B5). |
| R-8 | **Миграция БД повреждает общую прод-БД (данные enduro-trails).** | Низкий | Только аддитивно: `CREATE TABLE/INDEX IF NOT EXISTS`; существующие колонки не меняются; идемпотентно (AC-G4). |
| R-9 | **Self-hosting: изменения требуют рестарта прод-контейнера вне `Confirm Deploy`.** | Высокий (если нарушено) | Все изменения — обычный код, проходят `deploy-staging` (8501) → `Confirm Deploy` (ORCH-059). `STAGE_TRANSITIONS`/`QG_CHECKS` не трогаются; никакого внеочередного рестарта (AC-A5). |
| R-10 | **Конфликт точек интеграции A (merge-gate) и B (постановка в очередь).** | Низкий | Разные точки конвейера: B гейтит claim job (вход), A гейтит merge на ребре `deploy-staging→deploy`. Независимы; покрыть интеграционным тестом совместной работы (BRD §4.4). |

View File

@@ -0,0 +1,47 @@
---
type: review
work_item_id: ORCH-026
verdict: APPROVED
version: 1
---
# Review ORCH-026
## Summary
ORCH-026 реализует два уровня по ADR-001: **Уровень A** — сериализация merge/deploy внутри одного репо (переиспользует merge-lease ORCH-043/065 + единственная новая логика — безусловный pre-merge rebase под флагом `premerge_rebase_always`) и **Уровень B** — декларативные зависимости задач (аддитивная таблица `job_deps`, гейт `NOT EXISTS` в `claim_next_job`, leaf-модуль `src/task_deps.py`). Реализация минимально-инвазивна, строго соответствует ТЗ и ADR, обе фичи условны (kill-switch) и инертны без данных. Все 16 критериев приёмки выполнены. Полный прогон `pytest tests/ -q`**991 passed**, из них 50 новых ORCH-026-тестов зелёные. Документация обновлена в том же PR. **APPROVED.**
## Findings
### P0 — Blocker
- (нет)
### P1 — Must fix
- (нет)
### P2 — Should fix
- (нет)
### P3 — Nice to have
- [ ] PR-ветка несёт коммиты ORCH-073 (`main` ещё не получил merge #77, merge-base = `77abfb3`). Это ожидаемо по топологии (ORCH-026 (B) построен поверх уже отревьюенного предшественника ORCH-073 (A): у ORCH-073 есть собственные `12-review.md`/`13-test-report.md`/`14-deploy-log.md`) и фактически демонстрирует саму фичу A (rebase B на код A). Не блокирует; при merge в `main` приедут оба набора изменений — это корректно.
## Соответствие ТЗ и ADR
- **Уровень A (AC-A1…A7):** окно сериализации обеспечено существующим merge-lease без нового механизма (ADR §A-1/A-3/A-4). A-2 — `check_branch_mergeable` (`src/qg/checks.py`) под лизом при `premerge_rebase_always=True` всегда вызывает `auto_rebase_onto_main`, снимая short-circuit `branch_is_behind_main`; kill-switch off → поведение ORCH-043 1:1. `STAGE_TRANSITIONS`/`QG_CHECKS`/`Confirm Deploy` не тронуты — соответствует инвариантам §9. Никаких push/force в `main` (только `--force-with-lease` ветки).
- **Уровень B (AC-B1…B5):** гейт `NOT EXISTS (job_deps JOIN tasks WHERE stage!='done')` в `claim_next_job` (`src/db.py`) — job не выбирается, слот `max_concurrency` не занимается; при выключенном флаге / пустой таблице clause не добавляется (нулевая регрессия). `task_deps.py` — чистый leaf: `is_task_ready` (fail-open), итеративный WHITE/GREY/BLACK DFS-детектор циклов (защита от recursion-limit на проде), `handle_cycle` (Blocked+alert), `declare_dependency`, `ingest_plane_relations` (только `plane|hybrid`, дефолт `db` не ходит в сеть на горячем пути). reconciler F-1 получил Guard 3 (skip dep-заблокированных + backstop детект цикла); reaper не тронут (сканирует `running`).
- **Общие (AC-G1…G5):** контракт never-raise выдержан во всех новых функциях (try/except, консервативная деградация). Миграция строго аддитивна — `CREATE TABLE/INDEX IF NOT EXISTS`, без `REFERENCES`, схема `tasks`/`jobs` не изменена (AC-G4 OK на живой общей БД). Наблюдаемость — read-only блок `task_deps` в `GET /queue`. Реализация в точности по местам, указанным в ADR §«Места реализации».
## Качество кода
- Docstrings на всех публичных функциях, явно документирован контракт fail-open/fail-closed.
- SQL-гейт безопасен: `dep_gate` — константная строка (нет инъекции), таблица `job_deps` гарантированно создана в `init_db`.
- Переменные `plane_id`/`plane_project_id`/`task_id` в `start_pipeline` — в области видимости (проверено).
- Тесты содержательные: миграция, conditionality (kill-switch), циклы, видимость, observability, интеграция сериализации и зависимостей.
## Документация — обновлена (golden source)
Проверено: код в `src/` изменён → документация обновлена В ТОМ ЖЕ PR (разнесена по pipeline-коммитам ветки, что нормально):
- `docs/architecture/README.md` — разделы про очередь (`claim_next_job`-гейт), pre-merge rebase, «Зависимости задач: B ждёт A», `job_deps`, наблюдаемость (architect-коммит `f8ec1c2`). ✓
- `docs/work-items/ORCH-026/06-adr/ADR-001-merge-serialization-and-task-deps.md` + глобальный `docs/architecture/adr/adr-0015-task-deps-and-merge-serialization.md`. ✓
- `CLAUDE.md` — паспорт (очередь/сериализация). ✓
- `CHANGELOG.md` — запись `## [Unreleased]`. ✓
- `.env.example``ORCH_PREMERGE_REBASE_ALWAYS`/`ORCH_TASK_DEPS_ENABLED`/`ORCH_TASK_DEPS_SOURCE`. ✓
- `08-data-requirements.md` — таблица `job_deps`. ✓
Документация = golden source: требование выполнено.

View File

@@ -0,0 +1,75 @@
---
type: test-report
work_item_id: ORCH-026
result: PASS
---
# Test Report — ORCH-026
Задача: «Управление зависимостями задач (B ждёт A) в очереди» + сериализация merge/деплоя
одного репо. Ветка `feature/ORCH-026-b-a`. Review-вердикт: **APPROVED** (`12-review.md`).
## Окружение
- Python: 3.12.13
- pytest: 8.3.3
- Ветка: `feature/ORCH-026-b-a` (HEAD `aaa4829`)
- Прод-оркестратор (8500): `/health``{"status":"ok"}` (не перезапускался, self-hosting инвариант соблюдён)
- Дата: 2026-06-08
## Результаты по тест-плану (04-test-plan.yaml)
### Уровень A — сериализация merge/деплоя
| TC ID | Описание | Тест-функция | Результат |
|-------|----------|--------------|-----------|
| TC-A01 | Proactive pre-merge rebase (всегда, даже когда не behind) | `test_orch026_premerge_rebase::test_always_rebases_even_when_not_behind` | PASS |
| TC-A02 | Расширенное окно merge-lease, defer не откат; holder-aware release | `test_orch026_merge_serialize::test_second_task_same_repo_defers_not_rollback`, `test_holder_aware_release_keeps_foreign_lease` | PASS |
| TC-A03 | Сериализация строго per-repo (orchestrator ≠ enduro-trails) | `test_orch026_merge_serialize::test_serialization_is_strictly_per_repo` | PASS |
| TC-A04 | Restart-safe + реклейм мёртвого держателя lease | `test_orch026_merge_serialize::test_dead_holder_lease_is_reclaimed`, `test_stale_lease_age_reclaimed_on_acquire` | PASS |
| TC-A05 | Anti-livelock defer: bounded бюджет, эскалация | `test_orch026_merge_serialize::test_defer_budget_is_bounded` | PASS |
| TC-A06 | Условность/kill-switch: off + out-of-scope = no-op | `test_orch026_conditionality::test_out_of_scope_repo_is_noop_even_with_flag_on`, `test_premerge_rebase::test_flag_off_short_circuits_like_orch043` | PASS |
| TC-A07 | Self-hosting safety: только `--force-with-lease` на ветку, STAGE_TRANSITIONS не тронуты | `test_orch026_conditionality::test_premerge_only_force_with_lease_on_branch`, `test_stage_transitions_unchanged` | PASS |
| TC-A08 | Сквозной сценарий сериализации merge-окна | `test_orch026_serialize_integration::test_serialized_merge_window` | PASS |
### Уровень B — декларативные зависимости
| TC ID | Описание | Тест-функция | Результат |
|-------|----------|--------------|-----------|
| TC-B01 | Декларация/резолв blocked-by; never-raise при недоступности | `test_orch026_task_deps::test_add_dependency_declares_and_resolves`, `test_add_dependency_never_raises_on_bad_input` | PASS |
| TC-B02 | Гейт готовности: незавершённый depends-on → не ready; все done → ready | `test_orch026_task_deps::test_is_task_ready_blocked_then_ready`, `test_is_task_ready_no_deps_is_ready` | PASS |
| TC-B03 | Детект циклов A→B→A и длиннее; ацикличный → нет | `test_orch026_dep_cycles::test_detect_two_node_cycle`, `test_detect_longer_cycle`, `test_acyclic_graph_has_no_cycle`, `test_detect_cycle_never_raises_on_garbage` | PASS |
| TC-B04 | Цикл → Blocked + alert без падения воркера | `test_orch026_dep_cycles::test_handle_cycle_blocks_and_alerts`, `test_handle_cycle_never_raises_when_notify_fails` | PASS |
| TC-B05 | claim_next_job не клеймит заблокированную (слот свободен), разблокируется при done | `test_orch026_task_deps::test_claim_skips_dep_blocked_job`, `test_claim_prefers_unblocked_job_over_blocked` | PASS |
| TC-B06 | Видимость: строка ожидания в карточке; never-raise рендер | `test_orch026_dep_visibility::test_blocked_task_shows_waiting_line`, `test_render_never_raises_on_dep_error` | PASS |
| TC-B07 | reconciler F-1 не разблокирует dep-заблокированную | `test_orch026_task_deps::test_reconciler_skip_helper_honours_block` | PASS |
| TC-B08 | Сквозной: B стартует только после A→done; multiple predecessors | `test_orch026_deps_integration::test_b_waits_for_a_then_runs`, `test_multiple_predecessors_all_must_be_done`, `test_ingest_plane_relations_writes_db` | PASS |
### Общие / миграция / регресс
| TC ID | Описание | Тест-функция | Результат |
|-------|----------|--------------|-----------|
| TC-G01 | Аддитивная миграция job_deps: идемпотентна, данные сохранены | `test_orch026_migration::test_job_deps_table_created`, `test_job_deps_indices_created`, `test_migration_idempotent_and_preserves_data` | PASS |
| TC-G02 | Наблюдаемость GET /queue: read-only блок task_deps | `test_orch026_queue_observability::test_queue_endpoint_includes_task_deps`, `test_snapshot_*` | PASS |
| TC-G03 | Регресс: полный pytest зелёный | `tests/` (991 passed) | PASS |
## Smoke test API (прод 8500)
- `GET /health``{"status":"ok","service":"orchestrator"}` — OK
- `GET /status` → активные задачи отдаются, ORCH-026 (id 58) в стадии `testing` — OK
- `GET /queue` → counts/resilience/reconcile/reaper/merge_verify читаются; брейкер `closed`, preflight OK — OK
- Примечание: блок `task_deps` в `/queue` прода 8500 ОТСУТСТВУЕТ — ожидаемо: прод-контейнер несёт текущую задеплоенную версию, ORCH-026 ещё не выкатан (self-hosting, деплой на поздних стадиях). Фича наблюдаемости верифицирована in-branch тестом `test_queue_endpoint_includes_task_deps` (PASS) через TestClient на коде ветки.
## Вывод pytest
```
tests/test_orch026_*.py — 50 passed, 1 warning in 1.56s
tests/ — 991 passed, 1 warning in 26.52s
```
(единственный warning — PydanticDeprecatedSince20 в `src/config.py`, предсуществующий, не относится к ORCH-026)
## Покрытие критериев приёмки (03-acceptance-criteria.md)
Все 16 критериев (AC-A1…A7, AC-B1…B5, AC-G1…G5) покрыты прохождением соответствующих TC и
подтверждены review-вердиктом APPROVED. Регрессии merge-gate (ORCH-043), merge-verify
(ORCH-073), reconciler (ORCH-053/068), reaper (ORCH-065) не обнаружено.
## Итог
**PASS** — 50/50 новых ORCH-026-тестов зелёные, полный регресс 991 passed, smoke API OK,
прод-контейнер не затронут. Задача готова к переходу на `deploy-staging`.

View File

@@ -0,0 +1,12 @@
---
deploy_status: SUCCESS
work_item: ORCH-026
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,34 @@
---
staging_status: SUCCESS
timestamp: 2026-06-08T16:14:11+00:00
base_url: http://localhost:8501
---
# Staging Gate Log
Staging test suite completed. Exit code 0 → advance.
Canonical run (ORCH-048, ADR-001) inside the live staging container:
```
docker exec orchestrator-staging \
python3 /repos/orchestrator/scripts/staging_check.py \
--base-url http://localhost:8501 --mode stub
```
## Result: 8/10 checks PASS
- **Block A (SMOKE):** A1 /health, A2 /queue, A3 ORCH_STAGING=true — all PASS.
- **Block B (ACCESS):** B4 Plane sandbox (R), B5 Gitea orchestrator-sandbox (R+push), B6 registry isolation (sandbox present, prod ET/ORCH absent) — all PASS.
- **Block C (E2E, stub):** C7 create issue, C8 trigger pipeline — PASS.
REAL failed: **none** — all pipeline checks green.
## INFRA-WAIVED (ORCH-061)
```
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
```
C9a/C9b are the two known sandbox-infra-only checks (depend on SANDBOX bot accounts being members of the sandbox Plane project, not on the pipeline). They are tolerated because every REAL check is green; the script printed `INFRA-WAIVED:` and exited 0 (fail-closed semantics preserved: any REAL failure would still yield exit 1).

View File

@@ -0,0 +1,14 @@
---
post_deploy_status: HEALTHY
action_taken: NONE
work_item: ORCH-026
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,12 @@
---
deploy_status: SUCCESS
work_item: ORCH-067
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,32 @@
---
staging_status: SUCCESS
timestamp: 2026-06-08T10:32:02Z
base_url: http://localhost:8501
---
# Staging Gate Log
Staging test suite completed. All REAL checks passed (8/10), the two
sandbox-infra checks were waived per ORCH-061 → exit 0 → SUCCESS.
Canonical run (ORCH-048, ADR-001) inside the staging container:
```
docker exec orchestrator-staging \
python3 /repos/orchestrator/scripts/staging_check.py \
--base-url http://localhost:8501 --mode stub
```
## Result: 8/10 checks PASS — VERDICT SUCCESS (exit 0)
- Block A (SMOKE): A1 /health, A2 /queue, A3 ORCH_STAGING=true — PASS
- Block B (ACCESS): B4 Plane sandbox, B5 Gitea push, B6 registry isolation — PASS
- Block C (E2E, stub): C7 create issue, C8 trigger pipeline — PASS
REAL failed: none
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
Cleanup completed: test Plane SANDBOX issue deleted (HTTP 204), no branch created to delete.

View File

@@ -0,0 +1,14 @@
---
post_deploy_status: HEALTHY
action_taken: NONE
work_item: ORCH-067
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: QG-0 title-лимит → параметр ORCH_QG0_TITLE_MAX (дефолт 200)
Work Item ID: ORCH-069
## Description
TBD

View File

@@ -0,0 +1,76 @@
# BRD — ORCH-069: QG-0 title-лимит → параметр ORCH_QG0_TITLE_MAX (дефолт 200)
Work Item ID: ORCH-069
Тип: Enhancement (QoL / конфигурируемость)
Источник: Слава, 2026-06-08
Связано с: QG-0 (gate входа конвейера, `_qg0_errors`)
## 1. Проблема (As-Is)
QG-0 — первый quality gate конвейера. Он валидирует заголовок и описание задачи
до старта pipeline (`start_pipeline`) и в soft-режиме на `work_item.created`.
Верхний лимит длины заголовка задачи **захардкожен** в
`src/webhooks/plane.py:362`:
```python
if len(name) > 80:
errors.append("Title слишком длинный (максимум 80 символов)")
```
Лимит 80 — «гигиенический», а не структурный. Проверено, что **ниже по течению
ничего от значения 80 не зависит**:
- slug ветки режется независимо: `re.sub(...)[:30]` (`src/webhooks/plane.py:478`);
- БД `tasks.title TEXT` — без ограничения длины;
- Telegram-карточка использует `html.escape(title)` без обрезки;
- Plane хранит `name` самостоятельно.
Следствие: вполне валидные осмысленные заголовки длиной 81200 символов
отклоняются на входе конвейера без бизнес-причины.
## 2. Цель (To-Be)
Вынести верхний лимит длины заголовка QG-0 в конфигурируемый параметр со
значением по умолчанию **200** (вместо текущего хардкода 80). Расширить лимит
безопасно, сохранив возможность регулировать его через окружение, как и
остальные `ORCH_*` настройки.
## 3. Бизнес-ценность
- Меньше ложных отклонений валидных задач на входе конвейера (QoL для постановщика).
- Лимит становится операционно настраиваемым без правки кода и редеплоя
(изменение env-переменной).
- Изменение чисто аддитивное и обратносовместимое: дефолт 200 > прежних 80, поэтому
все заголовки, проходившие раньше, проходят и теперь.
## 4. Объём (Scope)
### В объёме
- Новый параметр Settings `qg0_title_max` (env `ORCH_QG0_TITLE_MAX`, дефолт 200).
- Замена хардкода `> 80` на `> settings.qg0_title_max` в `_qg0_errors`.
- Динамический текст ошибки с подстановкой актуального лимита.
- Graceful-поведение при невалидном/пустом значении env → дефолт 200, без падения процесса.
- Документация: `.env.example`, `.env.staging.example`, `CHANGELOG.md`,
при необходимости README-таблица конфигов / `CLAUDE.md`.
- Юнит-тесты на `_qg0_errors` с разными лимитами.
### Вне объёма (Out of scope)
- Slug-логика `[:30]` (`src/webhooks/plane.py:478`) — самодостаточна, не трогать.
- Нижний лимит заголовка (`< 5`) и лимит description (`< 20`) — оставить как есть.
- Схема БД, реестры `STAGE_TRANSITIONS` / `QG_CHECKS`, контракты `handle_*`.
- Soft-QG-0 на `work_item.created` (там только warning) — логика валидации общая
(`_qg0_errors`), отдельных изменений не требует и не вносит.
## 5. Заинтересованные стороны
- Owner / постановщик задач (Слава) — снижение ложных отклонений.
- Агенты конвейера — поведение QG-0 при старте pipeline.
## 6. Ограничения и риски (self-hosting)
- Правка касается работающего в проде инструмента (self-hosting). Прод-контейнер
`orchestrator` в рамках задачи **не рестартить**; обязательна страховка
`deploy-staging` (8501).
- Риск минимален: изменение обратносовместимо, изолировано в одной функции и одном
новом параметре config.
## 7. Допущения
- Механизм чтения env — стандартный `pydantic_settings.BaseSettings` с
`env_prefix = "ORCH_"`, как у остальных параметров.
- «Невалидное/пустое значение → дефолт 200» — требование graceful-деградации:
процесс не должен падать на старте из-за мусора в `ORCH_QG0_TITLE_MAX`
(нюанс реализации pydantic-валидации передаётся архитектору, см. 02-trz §5).

View File

@@ -0,0 +1,95 @@
# ТЗ — ORCH-069: QG-0 title-лимит → параметр ORCH_QG0_TITLE_MAX (дефолт 200)
Work Item ID: ORCH-069
## 1. Задействованные модули `src/`
| Файл | Текущее состояние | Требуемое изменение |
|------|-------------------|---------------------|
| `src/config.py` | `Settings(BaseSettings)`, `env_prefix = "ORCH_"` (строки 4, 347-349) | Добавить поле `qg0_title_max: int = 200` с комментарием-описанием. |
| `src/webhooks/plane.py` | `_qg0_errors` (строки 357-367), хардкод `if len(name) > 80:` (строка 362); `from ..config import settings` уже импортирован (строка 11) | Заменить хардкод `> 80` на `> settings.qg0_title_max`; текст ошибки — динамический с подстановкой лимита. |
Других модулей изменение не затрагивает.
## 2. Изменение config.py
Добавить в класс `Settings` новое поле (рядом с другими `ORCH_*` группами,
рекомендуется отдельный блок с комментарием):
```python
# ORCH-069: QG-0 upper title-length limit (entry gate _qg0_errors). The 80-char
# cap was a hygiene limit, not structural (slug is cut to [:30] independently,
# DB title TEXT is unbounded). Configurable via env ORCH_QG0_TITLE_MAX; default
# 200 (was hardcoded 80). Invalid/empty value -> default (graceful, no crash).
qg0_title_max: int = 200
```
- Env-переменная: `ORCH_QG0_TITLE_MAX` (автоматически из `env_prefix = "ORCH_"`).
- Тип `int`, дефолт `200`.
## 3. Изменение `_qg0_errors` (src/webhooks/plane.py)
Текущий блок (строки 362-363):
```python
if len(name) > 80:
errors.append("Title слишком длинный (максимум 80 символов)")
```
Требуемое:
```python
if len(name) > settings.qg0_title_max:
errors.append(
f"Title слишком длинный (максимум {settings.qg0_title_max} символов)"
)
```
Требования:
- Лимит берётся из `settings.qg0_title_max` (динамически, на каждый вызов — чтобы
тесты могли подменять значение через мок/патч settings).
- Текст ошибки содержит актуальное число лимита (для AC-1/AC-2: текст упоминает
200 / 120 соответственно).
- Нижний лимит заголовка `< 5` (строка 360-361) и проверка description `< 20`
(строка 364-365) — **не трогать**.
- Сигнатура `_qg0_errors(name, description) -> list` не меняется.
## 4. Поведение границы (точная семантика)
- Условие fail — строго `len(name) > limit`. То есть `len == limit` → PASS,
`len == limit + 1` → FAIL.
- При дефолте: 200 символов → PASS, 201 → FAIL.
- При `ORCH_QG0_TITLE_MAX=120`: 120 → PASS, 121 → FAIL.
## 5. Graceful-обработка невалидного значения (требование AC-3)
Требование: невалидное/отсутствующее `ORCH_QG0_TITLE_MAX` → используется дефолт 200,
процесс не падает.
Нюанс для архитектора/разработчика: `pydantic_settings` по умолчанию при
непарсящемся в `int` значении env (например `ORCH_QG0_TITLE_MAX=abc` или пустая
строка) выбрасывает `ValidationError` на инстанцировании `Settings()`
т.е. падение на старте процесса. Это противоречит требованию graceful.
Реализация должна обеспечить, что:
- отсутствие переменной → дефолт 200 (это стандартное поведение, ОК «из коробки»);
- пустая строка / нечисловое значение → дефолт 200 без исключения.
Способ (на усмотрение архитектора, без предписания со стороны аналитика) —
например field-validator с `mode="before"`, который при невалидном входе
возвращает дефолт. Конкретный механизм фиксируется в ADR на стадии architecture.
## 6. Изменения API
Нет. Эндпоинты не меняются.
## 7. Изменения схемы БД
Нет. `tasks.title TEXT` остаётся без ограничения длины.
## 8. Новые QG checks
Нет. Реестр `QG_CHECKS` и `STAGE_TRANSITIONS` не меняются. QG-0 — не зарегистрированный
stage-gate, а inline-валидация входа (`_qg0_errors`), её контракт сохраняется.
## 9. Артефакты pipeline, которые должны быть созданы/обновлены
- `.env.example` — добавить `ORCH_QG0_TITLE_MAX=200` с комментарием.
- `.env.staging.example` — добавить `ORCH_QG0_TITLE_MAX` (дефолт/комментарий).
- `CHANGELOG.md` — запись об ORCH-069.
- README-таблица конфигов / `CLAUDE.md` — обновить при наличии релевантной таблицы
параметров (по требованию reviewer; документация = golden source).
- Юнит-тесты (`tests/`) — см. `04-test-plan.yaml`.
## 10. Обратная совместимость
- Дефолт 200 > прежних 80 → все ранее проходившие заголовки проходят и теперь.
- Поведение при не заданном env идентично «как было», но с порогом 200 вместо 80.
- Изменение чисто аддитивное; откатов/миграций не требует.

View File

@@ -0,0 +1,56 @@
# Критерии приёмки — ORCH-069
Work Item ID: ORCH-069
Формат: каждый критерий имеет чёткое условие PASS/FAIL.
## AC-1 — Дефолтный лимит 200, граница на 201
**Дано:** env `ORCH_QG0_TITLE_MAX` не задан (используется дефолт 200), description валиден (≥ 20 символов).
**Тогда:**
- заголовок длиной 200 символов → `_qg0_errors` НЕ содержит ошибки про длину title (PASS);
- заголовок длиной 201 символ → `_qg0_errors` содержит ошибку про длину title, и текст ошибки упоминает «200».
**FAIL если:** на 200 появляется ошибка длины, либо на 201 ошибки нет, либо текст не упоминает 200.
## AC-2 — Настраиваемый лимит 120, граница на 121
**Дано:** `ORCH_QG0_TITLE_MAX=120` (через мок/патч settings в тесте), description валиден.
**Тогда:**
- заголовок 120 символов → нет ошибки длины title (PASS);
- заголовок 121 символ → есть ошибка длины title, текст упоминает «120».
**FAIL если:** граница срабатывает не на 121, либо текст ошибки упоминает не 120.
## AC-3 — Graceful при невалидном/пустом значении
**Дано:** `ORCH_QG0_TITLE_MAX` пустой (`""`) или нечисловой (`"abc"`).
**Тогда:**
- инстанцирование `Settings()` / импорт приложения НЕ выбрасывает исключение (процесс не падает);
- эффективное значение лимита = дефолт 200 (поведение AC-1 сохраняется).
**FAIL если:** старт процесса падает с `ValidationError`, либо лимит != 200.
## AC-4 — Нижние лимиты не сломаны
**Дано:** любое валидное значение `ORCH_QG0_TITLE_MAX`.
**Тогда:**
- заголовок длиной < 5 символов → `_qg0_errors` содержит ошибку «Title слишком короткий»;
- description длиной < 20 символов → `_qg0_errors` содержит ошибку «Description слишком короткий».
**FAIL если:** нижний лимит title или лимит description перестал срабатывать.
## AC-5 — Юнит-тесты зелёные
**Дано:** реализованные юнит-тесты на `_qg0_errors` с разными значениями лимита (мок settings).
**Тогда:** `pytest tests/ -q` проходит полностью (зелёный), включая новые тесты ORCH-069 и существующий набор.
**FAIL если:** хотя бы один тест падает.
## AC-6 — Документация обновлена в том же PR
**Дано:** PR с изменениями кода.
**Тогда в том же PR:**
- `.env.example` содержит `ORCH_QG0_TITLE_MAX` с дефолтом и комментарием;
- `.env.staging.example` содержит `ORCH_QG0_TITLE_MAX`;
- `CHANGELOG.md` содержит запись об ORCH-069;
- при наличии релевантной таблицы конфигов в README / `CLAUDE.md` — она обновлена.
**FAIL если:** какой-либо из обязательных файлов документации не обновлён (reviewer → REQUEST_CHANGES).
## AC-7 — Обратная совместимость
**Дано:** env не задан.
**Тогда:** любой заголовок, который проходил QG-0 при прежнем лимите 80 (len ≤ 80), проходит и теперь (len ≤ 200).
**FAIL если:** ранее валидный заголовок отклоняется.
## AC-8 — Изоляция изменений
**Тогда:** не изменены slug-логика (`[:30]`), схема БД, реестры `STAGE_TRANSITIONS` / `QG_CHECKS`, контракты `handle_*`, soft-QG-0 поведение (warning на `work_item.created`).
**FAIL если:** затронут любой из перечисленных вне-объёмных элементов.

View File

@@ -0,0 +1,112 @@
work_item: ORCH-069
description: >
Юнит-тесты для конфигурируемого верхнего лимита длины заголовка QG-0
(_qg0_errors) через параметр settings.qg0_title_max (env ORCH_QG0_TITLE_MAX,
дефолт 200). Тесты патчат settings.qg0_title_max (monkeypatch на объекте
src.config.settings, который импортирован в src.webhooks.plane) и проверяют
границы и тексты ошибок. Файл тестов: tests/test_qg0_title_limit.py.
tests:
- id: TC-01
type: unit
description: "Дефолтный лимит 200: заголовок ровно 200 символов -> нет ошибки длины title (PASS на границе)."
module: tests/test_qg0_title_limit.py
setup: "settings.qg0_title_max=200 (дефолт); name='x'*200; description валиден (>=20 символов)."
assert: "В списке _qg0_errors нет элемента про длину title."
covers: [AC-1]
expected: PASS
- id: TC-02
type: unit
description: "Дефолтный лимит 200: заголовок 201 символ -> ошибка длины title, текст упоминает '200'."
module: tests/test_qg0_title_limit.py
setup: "settings.qg0_title_max=200; name='x'*201; description валиден."
assert: "В _qg0_errors есть ошибка длины title и её текст содержит подстроку '200'."
covers: [AC-1]
expected: PASS
- id: TC-03
type: unit
description: "Настраиваемый лимит 120: заголовок 120 символов -> нет ошибки длины title."
module: tests/test_qg0_title_limit.py
setup: "monkeypatch settings.qg0_title_max=120; name='x'*120; description валиден."
assert: "Нет ошибки длины title."
covers: [AC-2]
expected: PASS
- id: TC-04
type: unit
description: "Настраиваемый лимит 120: заголовок 121 символ -> ошибка длины title, текст упоминает '120'."
module: tests/test_qg0_title_limit.py
setup: "monkeypatch settings.qg0_title_max=120; name='x'*121; description валиден."
assert: "Есть ошибка длины title и её текст содержит подстроку '120' (и НЕ '80')."
covers: [AC-2]
expected: PASS
- id: TC-05
type: unit
description: "Graceful: невалидное (нечисловое) значение env ORCH_QG0_TITLE_MAX не роняет инстанцирование Settings и даёт дефолт 200."
module: tests/test_qg0_title_limit.py
setup: "monkeypatch.setenv('ORCH_QG0_TITLE_MAX','abc'); создать новый экземпляр Settings()."
assert: "Settings() не выбрасывает исключение; settings.qg0_title_max == 200."
covers: [AC-3]
expected: PASS
- id: TC-06
type: unit
description: "Graceful: пустая строка env ORCH_QG0_TITLE_MAX -> дефолт 200, без исключения."
module: tests/test_qg0_title_limit.py
setup: "monkeypatch.setenv('ORCH_QG0_TITLE_MAX',''); создать новый экземпляр Settings()."
assert: "Settings() не падает; settings.qg0_title_max == 200."
covers: [AC-3]
expected: PASS
- id: TC-07
type: unit
description: "Корректное числовое env -> применяется заданное значение (sanity положительного пути)."
module: tests/test_qg0_title_limit.py
setup: "monkeypatch.setenv('ORCH_QG0_TITLE_MAX','150'); создать новый экземпляр Settings()."
assert: "settings.qg0_title_max == 150."
covers: [AC-2, AC-3]
expected: PASS
- id: TC-08
type: unit
description: "Нижний лимит title не сломан: заголовок < 5 символов -> ошибка 'Title слишком короткий' при любом верхнем лимите."
module: tests/test_qg0_title_limit.py
setup: "settings.qg0_title_max=200; name='abc' (3 символа); description валиден."
assert: "В _qg0_errors есть ошибка короткого title."
covers: [AC-4]
expected: PASS
- id: TC-09
type: unit
description: "Лимит description не сломан: description < 20 символов -> ошибка 'Description слишком короткий'."
module: tests/test_qg0_title_limit.py
setup: "settings.qg0_title_max=200; name валиден (>=5, <=200); description='short'."
assert: "В _qg0_errors есть ошибка короткого description."
covers: [AC-4]
expected: PASS
- id: TC-10
type: unit
description: "Обратная совместимость: заголовок длиной 81-200 (ранее отклонялся лимитом 80) теперь проходит при дефолте."
module: tests/test_qg0_title_limit.py
setup: "settings.qg0_title_max=200; name='x'*100; description валиден."
assert: "Нет ошибки длины title (раньше при лимите 80 была бы)."
covers: [AC-7]
expected: PASS
- id: TC-11
type: unit
description: "Полный набор тестов зелёный (регрессия не внесена)."
module: tests/
command: "pytest tests/ -q"
assert: "Все тесты проходят."
covers: [AC-5]
expected: PASS
notes:
- "settings импортирован в src.webhooks.plane как 'from ..config import settings', _qg0_errors читает settings.qg0_title_max динамически -> monkeypatch на src.config.settings.qg0_title_max (или импортируемом объекте) меняет поведение в рамках теста."
- "Для TC-05/06/07 нужен СВЕЖИЙ экземпляр Settings(): глобальный src.config.settings создаётся один раз на импорт, поэтому env-тесты инстанцируют Settings() локально, а не полагаются на готовый синглтон."
- "Тесты не требуют сети, БД, агентов или FastAPI TestClient — чистая проверка leaf-функции _qg0_errors и парсинга Settings."

View File

@@ -0,0 +1,143 @@
# ADR-001: Конфигурируемый QG-0 title-лимит с graceful-деградацией env
## Статус
Accepted
## Контекст
QG-0 — inline-валидация входа конвейера (`_qg0_errors` в `src/webhooks/plane.py`),
вызывается из `start_pipeline` (hard-блок) и из `handle_work_item_created`
(soft-warning). Верхний лимит длины заголовка захардкожен: `if len(name) > 80`.
BRD/ТЗ (ORCH-069) установили, что лимит 80 — гигиенический, а не структурный:
ниже по течению от него ничего не зависит (slug режется независимо `[:30]`,
`tasks.title TEXT` без ограничения, Telegram/Plane хранят/экранируют сами).
Валидные заголовки 81200 символов отклоняются на входе без бизнес-причины.
Требуется:
1. Вынести лимит в конфигурируемый параметр `ORCH_QG0_TITLE_MAX`, дефолт 200.
2. **Graceful-деградация** (AC-3): пустое/нечисловое значение env → дефолт 200
**без падения процесса**. Это и есть единственное нетривиальное архитектурное
решение задачи: `pydantic_settings` v2 по умолчанию при непарсящемся в `int`
значении env бросает `ValidationError` на инстанцировании `Settings()`
т.е. краш на старте контейнера (`settings = Settings()` на module-import,
`src/config.py:352`). Для self-hosting это означало бы падение прод-инструмента
из-за опечатки в env — недопустимо.
Стек подтверждён: `pydantic==2.13.4`, `pydantic-settings==2.5.0` (v2 API).
## Решение
### Р-1. Новый параметр Settings
В `src/config.py`, в класс `Settings`, добавить поле (отдельный блок с
комментарием, рядом с прочими `ORCH_*`):
```python
# ORCH-069: QG-0 upper title-length limit (entry gate _qg0_errors).
# 80-char cap was a hygiene limit, not structural. Env ORCH_QG0_TITLE_MAX;
# default 200 (was hardcoded 80). Invalid/empty -> default (graceful, no crash).
qg0_title_max: int = 200
```
Env-имя выводится автоматически из `env_prefix = "ORCH_"``ORCH_QG0_TITLE_MAX`.
### Р-2. Механизм graceful-деградации — `field_validator(mode="before")`
Выбран **pydantic v2 `field_validator` с `mode="before"`** как
минимально-инвазивный, локальный для одного поля механизм. Валидатор перехватывает
сырое значение env ДО стандартного `int`-парсинга и при невалидном/пустом входе
возвращает дефолт `200`, гася `ValidationError`:
```python
from pydantic import field_validator
@field_validator("qg0_title_max", mode="before")
@classmethod
def _qg0_title_max_default(cls, v):
# Graceful (ORCH-069 AC-3): empty / non-numeric env -> default 200,
# process must not crash on startup. Never raises.
try:
if v is None or (isinstance(v, str) and v.strip() == ""):
return 200
return int(v)
except (TypeError, ValueError):
return 200
```
Семантика:
- переменная не задана → pydantic не вызывает validator с env, берётся дефолт поля
`200` (стандартное поведение «из коробки»);
- `""`, `"abc"`, мусор → validator возвращает `200`, исключения нет;
- `"120"``int("120") == 120`.
**Почему именно так (рассмотренные альтернативы):**
- *`Optional[int] + None-fallback на месте чтения`* — отвергнуто: размазывает
дефолт по call-site'ам, легко забыть, тип поля перестаёт быть «честным `int`».
- *try/except вокруг `Settings()` на module-level* — отвергнуто: глушит ВСЕ
ошибки конфигурации (маскирует реальные проблемы других полей), слишком грубо.
- *кастомный тип / `Annotated`-валидатор* — избыточно для одного поля.
- `field_validator(mode="before")` локален, не трогает остальные поля, не меняет
публичный тип `int`, тестируется напрямую через `Settings(qg0_title_max=...)` и
env-патч. Контракт «never-raise» консистентен с общим стилем кодовой базы
(`_qg0_errors`, парсеры — defensive).
### Р-3. Использование лимита в `_qg0_errors`
Хардкод `> 80` → динамическое чтение `settings.qg0_title_max` **на каждый вызов**
(чтобы тест мог патчить `settings`), текст ошибки — f-string с актуальным числом:
```python
if len(name) > settings.qg0_title_max:
errors.append(
f"Title слишком длинный (максимум {settings.qg0_title_max} символов)"
)
```
`settings` уже импортирован в `plane.py`. Сигнатура `_qg0_errors(name, description)
-> list` не меняется. Нижние лимиты (`< 5` title, `< 20` description) — без правок.
Граница (ТЗ §4): fail строго при `len(name) > limit``len == limit` PASS,
`limit + 1` FAIL.
### Р-4. Что НЕ меняется (инварианты)
- `STAGE_TRANSITIONS`, `QG_CHECKS` — QG-0 не зарегистрированный stage-gate, а
inline-валидация; реестры не трогаются.
- Схема БД (`tasks.title TEXT`), API, контракты `handle_*`, slug-логика `[:30]`,
soft-QG-0 поведение (общая функция `_qg0_errors`, отдельной правки не требует).
- Топология/инфраструктура (`07-infra-requirements.md`**N/A**) и схема данных
(`08-data-requirements.md`**N/A**) не затрагиваются.
## Последствия
### Плюсы
- Лимит операционно настраивается через env без правки кода и редеплоя кода.
- Чисто аддитивно и обратносовместимо: дефолт 200 > прежних 80 → все ранее
проходившие заголовки проходят (AC-7).
- Опечатка в `ORCH_QG0_TITLE_MAX` не роняет прод-процесс (критично для
self-hosting): graceful-fallback на 200.
- Изменение изолировано в одной функции + одном поле config + одном валидаторе.
### Минусы / ограничения
- Невалидное env «тихо» проглатывается → оператор не сразу заметит опечатку
(лимит молча станет 200). Принято как осознанный trade-off: устойчивость
процесса важнее громкости (consistency с требованием AC-3). Рекомендация:
при желании усилить наблюдаемость — `logger.warning` в validator; **не вводим**
по умолчанию, т.к. на этапе валидации settings логгер может быть не сконфигурён,
и это вне объёма ORCH-069 (можно отдельной QoL-задачей).
- Дефолт 200 — тоже эвристика; структурного верхнего предела по-прежнему нет
(его и не требуется — БД/slug/UI к длине устойчивы).
### Влияние на self-hosting
Прод-контейнер `orchestrator` **не рестартить** в рамках задачи. Изменение
прокатывается штатно через обязательный `deploy-staging`-гейт (8501) перед
прод-деплоем. Риск отказа на старте после деплоя снят самим механизмом Р-2
(graceful), что дополнительно снижает self-hosting-риск.
### Тестируемость (вход для стадий development/testing)
- `_qg0_errors`: патч `settings.qg0_title_max` → проверка границ 200/201 (AC-1),
120/121 (AC-2), нижних лимитов (AC-4).
- validator: `Settings(qg0_title_max="abc")` / `=""` / env-патч → значение 200,
без исключения (AC-3).
## Ссылки
- BRD: `docs/work-items/ORCH-069/01-brd.md`
- ТЗ: `docs/work-items/ORCH-069/02-trz.md`
- Acceptance: `docs/work-items/ORCH-069/03-acceptance-criteria.md`
- Тех-риски: `docs/work-items/ORCH-069/10-tech-risks.md`
</content>
</invoke>

View File

@@ -0,0 +1,21 @@
# Технические риски — ORCH-069
Work Item ID: ORCH-069
Уровень общего риска: **низкий** (аддитивное, обратносовместимое, изолированное изменение).
| # | Риск | Вероятность | Влияние | Митигация |
|---|------|-------------|---------|-----------|
| R-1 | `ValidationError` на старте при мусоре в `ORCH_QG0_TITLE_MAX` → краш прод-процесса (self-hosting) | Средняя (опечатка в env) | Высокое (падение инструмента всех проектов) | `field_validator(mode="before")` гасит невалидный вход → дефолт 200 (ADR Р-2, AC-3). never-raise. |
| R-2 | Чтение лимита один раз на module-import вместо per-call → тесты не смогут патчить settings | Низкая | Среднее (нетестируемость AC-2) | `_qg0_errors` читает `settings.qg0_title_max` динамически на каждый вызов (ADR Р-3). |
| R-3 | Off-by-one на границе (`>=` вместо `>`) | Низкая | Низкое (1 символ) | Явная семантика `len > limit` зафиксирована (ТЗ §4, AC-1/AC-2); тесты на 200/201, 120/121. |
| R-4 | Регресс нижних лимитов (`< 5` title, `< 20` description) при правке функции | Низкая | Среднее | Трогать только верхний лимит; AC-4 покрывает нижние; диф минимален. |
| R-5 | Тихое проглатывание невалидного env → оператор не заметит опечатку | Средняя | Низкое (лимит молча = 200, конвейер работает) | Осознанный trade-off (ADR «Минусы»): устойчивость > громкость. Опц. `logger.warning` — вне объёма. |
| R-6 | Случайное затрагивание вне-объёмных элементов (slug `[:30]`, БД, реестры, `handle_*`, soft-QG-0) | Низкая | Среднее | AC-8 — изоляция; reviewer проверяет диф; ADR Р-4 фиксирует инварианты. |
| R-7 | Документация не обновлена в том же PR (`.env.example`, `.env.staging.example`, `CHANGELOG.md`) | Средняя | Среднее (reviewer REQUEST_CHANGES) | AC-6 чек-лист; документация = golden source (правило 2 CLAUDE.md). |
## Не-риски (явно)
- Схема БД — не меняется (`tasks.title TEXT` без ограничения).
- API/эндпоинты — не меняются.
- Топология/контейнеры/порты — не меняются.
- Откат/миграция — не требуется (дефолт 200 > 80, чисто аддитивно).
</content>

View File

@@ -0,0 +1,68 @@
---
type: review
work_item_id: ORCH-069
verdict: APPROVED
version: 3
---
# Review ORCH-069
## Summary
Реализация конфигурируемого QG-0 title-лимита `ORCH_QG0_TITLE_MAX` (дефолт 200)
выполнена **дословно по ТЗ/ADR** и качественно. Поле `Settings.qg0_title_max`,
graceful `field_validator(mode="before")` (never-raise → дефолт 200), динамическое
чтение `settings.qg0_title_max` в `_qg0_errors` с f-string-текстом ошибки. Код
изолирован (затронуты только `src/config.py` и `src/webhooks/plane.py`), инварианты
не нарушены, нижние лимиты сохранены. Свежий полный прогон на текущем состоянии
ветки: `pytest tests/ -q`**863 passed** (включая 10 новых тестов ORCH-069,
файл `tests/test_qg0_title_limit.py`, все зелёные). Документация обновлена в том же
PR полностью. Блокирующих и must-fix findings нет → **APPROVED**.
## Соответствие ТЗ / ADR
- `src/config.py` — поле `qg0_title_max: int = 200` + валидатор `_qg0_title_max_default`
(`mode="before"`, try/except → 200 при `None`/пустой/нечисловой): 1:1 с ADR Р-1/Р-2
и ТЗ §2/§5. ✓
- `src/webhooks/plane.py` — хардкод `> 80` заменён на `> settings.qg0_title_max`,
текст ошибки динамический (f-string с актуальным числом); сигнатура `_qg0_errors`,
нижний лимит title `< 5`, проверка description `< 20` не тронуты: ADR Р-3, ТЗ §3/§4. ✓
- Граница строгая (`len == limit` PASS, `limit+1` FAIL) — подтверждена tc01tc04. ✓
- Инварианты (ADR Р-4 / AC-8): `STAGE_TRANSITIONS`, `QG_CHECKS`, схема БД, slug `[:30]`,
soft-QG-0, API — НЕ изменены (diff `src/` = только 2 файла). ✓
## Acceptance criteria
- AC-1 (дефолт 200, граница 201, текст упоминает 200) — tc01/tc02 ✓
- AC-2 (лимит 120, граница 121, текст 120 не 80) — tc03/tc04 ✓
- AC-3 (graceful пустое/`abc` → 200 без краха) — tc05/tc06 + позитив tc07 + валидатор ✓
- AC-4 (нижние лимиты title<5 / desc<20) — tc08/tc09 ✓
- AC-5 (pytest зелёный) — 863 passed ✓
- AC-6 (документация в том же PR) — выполнен полностью ✓
- AC-7 (обратная совместимость, ≤80 проходит при 200) — tc10 ✓
- AC-8 (изоляция изменений) — ✓
## Findings
### P0 — Blocker
- (нет)
### P1 — Must fix
- (нет)
### P2 — Should fix
- (нет)
### P3 — Nice-to-have (не блокирует)
- В конце `06-adr/ADR-001-configurable-qg0-title-limit.md` присутствуют артефактные
хвостовые теги (`</content>`, `</invoke>`). Косметика в артефакте стадии architecture;
на корректность кода/контракта не влияет. Править артефакт чужой стадии в рамках
ревью не уполномочен — отмечено для будущей чистки.
## Документация
- `.env.example` — добавлен `ORCH_QG0_TITLE_MAX=200` с комментарием. ✓
- `.env.staging.example` — добавлен `ORCH_QG0_TITLE_MAX=200`. ✓
- `CHANGELOG.md` — подробная запись об ORCH-069 (раздел Added). ✓
- `README.md` — таблица env-конфигов дополнена строкой `ORCH_QG0_TITLE_MAX`. ✓
- ADR `06-adr/ADR-001-configurable-qg0-title-limit.md` — присутствует, согласован
с кодом. ✓
- `docs/architecture/README.md` / `CLAUDE.md` — обновления не требуют (QG-0 — inline
soft/hard-валидация входа, не зарегистрированный stage-gate; API/стадии/QG-реестр
не менялись). ОК.

View File

@@ -0,0 +1,98 @@
---
type: test-report
work_item_id: ORCH-069
result: PASS
---
# Test Report — ORCH-069
QG-0 title-лимит → параметр `ORCH_QG0_TITLE_MAX` (дефолт 200)
## Окружение
- Python: 3.12.13
- pytest: 8.3.3 (plugins: anyio-4.13.0, asyncio-0.23.8; asyncio mode=auto)
- Ветка: `feature/ORCH-069-qg-0-title-orch-qg0-title-max-`
- Worktree: `/repos/_wt/orchestrator/feature_ORCH-069-qg-0-title-orch-qg0-title-max-`
- Prod-health (8500): `{"status":"ok","service":"orchestrator"}` — не трогался (self-hosting safety)
- Дата: 2026-06-08
## Предусловия
- Review-вердикт `12-review.md`: **APPROVED** (version 3) ✓
- Изменения изолированы: `src/config.py`, `src/webhooks/plane.py` (+ тесты, + документация)
## Результаты по тест-плану (04-test-plan.yaml)
| TC ID | Описание | Покрывает | Результат |
|-------|----------|-----------|-----------|
| TC-01 | Дефолт 200: title=200 → нет ошибки длины (граница PASS) | AC-1 | PASS |
| TC-02 | Дефолт 200: title=201 → ошибка длины, текст упоминает «200» | AC-1 | PASS |
| TC-03 | Лимит 120: title=120 → нет ошибки длины | AC-2 | PASS |
| TC-04 | Лимит 120: title=121 → ошибка, текст «120» (не «80») | AC-2 | PASS |
| TC-05 | Graceful: env `abc` → дефолт 200, без краха `Settings()` | AC-3 | PASS |
| TC-06 | Graceful: пустой env `""` → дефолт 200, без исключения | AC-3 | PASS |
| TC-07 | Валидный env `150` → применяется 150 (позитивный путь) | AC-2, AC-3 | PASS |
| TC-08 | Нижний лимит title < 5 не сломан | AC-4 | PASS |
| TC-09 | Лимит description < 20 не сломан | AC-4 | PASS |
| TC-10 | Обратная совместимость: title 81200 проходит при дефолте | AC-7 | PASS |
| TC-11 | Полный набор тестов зелёный (нет регрессии) | AC-5 | PASS |
## Сопоставление с критериями приёмки (03-acceptance-criteria.md)
| AC | Критерий | Статус |
|----|----------|--------|
| AC-1 | Дефолт 200, граница на 201, текст упоминает 200 | PASS (TC-01/02) |
| AC-2 | Настраиваемый лимит 120, граница 121, текст 120 | PASS (TC-03/04/07) |
| AC-3 | Graceful при пустом/нечисловом значении → 200 | PASS (TC-05/06) |
| AC-4 | Нижние лимиты title<5 / description<20 не сломаны | PASS (TC-08/09) |
| AC-5 | Юнит-тесты зелёные (весь набор) | PASS (863 passed) |
| AC-6 | Документация в том же PR (.env.example, .env.staging.example, CHANGELOG, README) | PASS (подтверждено review) |
| AC-7 | Обратная совместимость (≤80 проходит при 200) | PASS (TC-10) |
| AC-8 | Изоляция: slug `[:30]`, БД, STAGE_TRANSITIONS/QG_CHECKS, handle_* не тронуты | PASS (diff = 2 файла src/) |
## Smoke test API (prod 8500, read-only)
- `GET /health``{"status":"ok","service":"orchestrator"}` — OK
- `GET /status` → отдаёт активные задачи (ORCH-069 в стадии `testing`) — OK
- `GET /queue``counts: queued=0 running=1 done=459 failed=4 cancelled=1`; breaker `closed`, preflight ok — OK
## Целевой прогон ORCH-069 (tests/test_qg0_title_limit.py)
```
collected 10 items
tests/test_qg0_title_limit.py::test_tc01_default_limit_200_boundary_pass PASSED
tests/test_qg0_title_limit.py::test_tc02_default_limit_200_boundary_fail PASSED
tests/test_qg0_title_limit.py::test_tc03_custom_limit_120_boundary_pass PASSED
tests/test_qg0_title_limit.py::test_tc04_custom_limit_120_boundary_fail PASSED
tests/test_qg0_title_limit.py::test_tc05_graceful_non_numeric_env PASSED
tests/test_qg0_title_limit.py::test_tc06_graceful_empty_env PASSED
tests/test_qg0_title_limit.py::test_tc07_valid_numeric_env PASSED
tests/test_qg0_title_limit.py::test_tc08_short_title_still_errors PASSED
tests/test_qg0_title_limit.py::test_tc09_short_description_still_errors PASSED
tests/test_qg0_title_limit.py::test_tc10_backward_compat_titles_81_to_200 PASSED
======================== 10 passed, 1 warning in 0.31s =========================
```
## Полный прогон (pytest tests/ -q)
```
........................................................................ [ 8%]
........................................................................ [ 16%]
........................................................................ [ 25%]
........................................................................ [ 33%]
........................................................................ [ 41%]
........................................................................ [ 50%]
........................................................................ [ 58%]
........................................................................ [ 66%]
........................................................................ [ 75%]
........................................................................ [ 83%]
........................................................................ [ 91%]
....................................................................... [100%]
863 passed, 1 warning in 21.49s
```
(Единственный warning — PydanticDeprecatedSince20 в `src/config.py:5`, существующий
class-based config; к ORCH-069 не относится, не является ошибкой.)
## Итог
**PASS** — все 11 TC из тест-плана пройдены, все 8 критериев приёмки выполнены,
полный регресс зелёный (863 passed), smoke-тесты API OK. Регрессии не внесены.
Задача готова к переходу на стадию `deploy-staging`.

View File

@@ -0,0 +1,12 @@
---
deploy_status: SUCCESS
work_item: ORCH-069
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,25 @@
---
staging_status: SUCCESS
timestamp: 2026-06-08T11:20:02+00:00
base_url: http://localhost:8501
---
# Staging Gate Log
Staging test suite completed via `docker exec orchestrator-staging python3 /repos/orchestrator/scripts/staging_check.py --base-url http://localhost:8501 --mode stub` (run through the Docker Engine API since the `docker` CLI is not installed on the host; equivalent in-container execution per ORCH-048/ADR-001).
**Result: 8/10 checks PASS — exit code 0 → SUCCESS.**
All REAL (pipeline) checks are green. The two failing checks are the known SANDBOX_INFRA checks (C9a/C9b), waived per ORCH-061 because 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
```
## Block results
- **[Block A] SMOKE** — A1 /health 200 ok ✓ · A2 /queue 200 with counts ✓ · A3 ORCH_STAGING=true ✓
- **[Block B] ACCESS** — B4 Plane sandbox accessible ✓ · B5 Gitea orchestrator-sandbox push=true ✓ · B6 Registry isolation (sandbox present, prod ET/ORCH absent) ✓
- **[Block C] E2E (stub)** — C7 Create issue in Plane SANDBOX ✓ · C8 Trigger pipeline via /webhook/plane ✓ · C9a Branch appears in orchestrator-sandbox ✗ (SANDBOX_INFRA, waived) · C9b Analyst job enqueued ✗ (SANDBOX_INFRA, waived)
REAL failed: none. SANDBOX_INFRA failed: C9a, C9b (waived). tolerance: staging_infra_tolerance_enabled=True.

View File

@@ -0,0 +1,25 @@
---
security_status: PASS
secrets_found: 0
deps_blocking: 0
deps_warning: 4
deps_audit_degraded: false
---
# Security Report — ORCH-069
Детерминированный security-гейт (ORCH-022): secret-scanning (gitleaks, offline) + dependency audit (pip-audit). Машинный вердикт читается ТОЛЬКО из frontmatter выше.
## Verdict
clean: 0 secrets, 0 blocking CVE(s)
## Secrets
- None
## Dependencies (blocking)
- None
## Dependencies (warning)
- `pytest==8.3.3` — GHSA-6w46-j5rx-g56g severity=UNKNOWN fix=9.0.3
- `starlette==0.38.6` — PYSEC-2026-161 severity=UNKNOWN fix=1.0.1
- `starlette==0.38.6` — GHSA-f96h-pmfr-66vw severity=UNKNOWN fix=0.40.0
- `starlette==0.38.6` — GHSA-2c2j-9gv5-cj73 severity=UNKNOWN fix=0.47.2

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

@@ -0,0 +1,7 @@
# Business Request: CRIT: эрозия main — код ORCH-067/069 затёрт ребейзами, не доехал
Work Item ID: ORCH-073
## Description
TBD

View File

@@ -0,0 +1,98 @@
# 01 — BRD: ORCH-073 — CRIT: эрозия main (код ORCH-067/069 затёрт ребейзами, не доехал)
- **Work Item:** ORCH-073
- **Тип:** BUG CRITICAL — целостность `main`, накопительный регресс/эрозия
- **Репозиторий:** orchestrator (self-hosting)
- **Ветка:** `feature/ORCH-073-crit-main-orch-067-069`
- **Связь:** усиливает/чинит ORCH-071 (merge-verify); НЕ покрыт ORCH-071.
## 1. Бизнес-проблема
Код успешно «задеплоенных» и переведённых в `done` задач **ORCH-067** (tracker bump,
Plane-статусы, кликабельные ссылки `plane_issue_link`) и **ORCH-069** (`qg0_title_max`)
**физически отсутствовал в `origin/main`**, хотя обе прошли весь конвейер, Confirm Deploy,
merge-verify `CONFIRMED` и стали `done`. В `main` попадали только их **docs-коммиты**
(staging-log / verdict через отдельные авто docs-PR), но НЕ код feature-веток.
Внешнее проявление (нашёл Слава, 08.06): «ссылок на задачу в Plane нет», карточка Telegram
показывает сырой номер задачи вместо кликабельной ссылки — потому что код ссылок есть в ветке
ORCH-067, но не в `main`.
**Накопительный характер:** каждая новая задача срезает ветку от УСТАРЕВШЕГО `main` и при merge
тихо (без конфликт-маркеров) затирает код предшественника. Уже потеряны ORCH-067 и ORCH-069;
без системного фикса теряется код каждой следующей задачи с правкой `CHANGELOG.md`.
## 2. Подтверждённый root cause (git-аудит 08.06, не гипотеза)
1. **`verify_merged_to_main` подтверждает merge по ложному признаку.**
`src/merge_gate.py::verify_merged_to_main` возвращает `True`, если выполнено **ЛИБО**
`pr_already_merged(repo, branch)`, **ЛИБО** `git merge-base --is-ancestor <sha> origin/main`.
Первая ветка (`pr_already_merged`) и есть дыра.
2. **`pr_already_merged` засчитывает ЛЮБОЙ merged PR ветки.**
`src/merge_gate.py::pr_already_merged` делает `GET /pulls?state=all&head=<branch>` и
возвращает `True`, если **хоть один** PR `merged==True`. У одной ветки несколько PR
(code-PR + авто docs-PR со staging/deploy-логами). Сливается docs-PR → функция говорит
«already-merged» → `verify_merged_to_main`=`True` → merge-verify `CONFIRMED``done`,
хотя code-PR НЕ слит. **Ложно-зелёный.**
3. **CHANGELOG.md-ребейзы — вторичный усилитель.**
Merge-gate `auto_rebase_onto_main` при конфликте `CHANGELOG.md` откатывает `deploy-staging →
development`; повторный ребейз ветки от старого `main` несёт устаревшие версии файлов
(`notifications.py`/`config.py`/`webhooks/plane.py`), которые при merge тихо затирают
соседний код (фантом-эффект, как в ORCH-071, без конфликт-маркеров).
> Уточнение для архитектора: в ТЗ упомянута «инвертированная проверка `merge-base --is-ancestor
> origin/main HEAD` (merge_gate.py ~76)» — это `branch_is_behind_main` (детектор «ветка
> свежая»), он корректен для своей цели. Фактический дефект merge-verify — это OR-ветка
> `pr_already_merged` в `verify_merged_to_main` (строка ~649), которая засчитывает docs-PR.
## 3. Состояние на момент анализа (G1)
Аудит `origin/main` показал, что **восстановительный PR #76** (`restore(main): re-merge
ORCH-067 + ORCH-069 (ORCH-073)`) уже вернул код в `main`:
- `plane_issue_link` присутствует (`src/notifications.py`), `qg0_title_max` присутствует
(`src/config.py`, `src/webhooks/plane.py`), `verify_merged_to_main` присутствует.
Таким образом **G1 (восстановление кода) фактически выполнено** ручным restore-PR. Задача
ORCH-073 должна **подтвердить и зафиксировать** это в критериях приёмки (AC-1) и сосредоточиться
на **системном фиксе навсегда** (G2G5 / FR-1…FR-5), иначе регресс повторится.
## 4. Цели (Goals)
- **G1.** КОД ORCH-067 и ORCH-069 присутствует в `origin/main` одновременно с ORCH-071
(подтвердить restore-PR #76, зафиксировать маркеры > 0). Pytest зелёный. Прод задеплоен.
- **G2 (FR-2/FR-3).** `merge`/`pr_already_merged` различают **code-PR** и **docs-PR** — merge
засчитывается только за PR с кодом ветки (`base==main`, `head==<feature-branch>`).
- **G3 (FR-1, ядро).** `verify_merged_to_main` подтверждает merge **ТОЛЬКО** по факту «deployed
SHA — предок `origin/main`». PR-флаги вспомогательны, не достаточны.
- **G4 (FR-4).** Защита от CHANGELOG-затирания: `.gitattributes` с `CHANGELOG.md merge=union`
(+ опц. `docs/*.md merge=union` для append-only).
- **G5 (FR-5, регресс-гард навсегда).** После деплоя — sanity-проверка целостности `main`:
deployed SHA в `main` И набор маркеров ранее-merged задач не уменьшился. Откат соседнего кода
→ alert «main regressed», задача НЕ `done`.
## 5. Не-цели (Out of scope)
- Не менять Plane / схему БД.
- Не отменять self-hosting safety (не ронять прод, merge только через PR-API, без force-push в `main`).
- Не менять ручной гейт `Confirm Deploy`.
- Не менять поведение merge/verify для non-self репозиториев (enduro-trails) — обратная совместимость.
## 6. Инварианты
- **INV-1.** never-raise на верификации (alert, не падение).
- **INV-2.** self-hosting safety: прод не падает; merge только PR-API, без force-push в `main`.
- **INV-3.** ручной `Confirm Deploy` сохранён.
- **INV-4.** Идемпотентность: повторный прогон / reaper не делает второй merge; idempotency
опирается на «SHA-в-main», а не на «любой merged PR».
- **INV-5.** Обратная совместимость non-self (enduro): поведение merge/verify без изменений.
## 7. Заинтересованные стороны
- **Owner / Слава** — потребитель (видит кликабельные ссылки в карточке; доверие к merge-verify).
- **Все проекты на инстансе** (enduro-trails) — общий `main`/очередь/БД; регресс орка = групповой риск.
## 8. Срочность
КРИТИКАЛ. Без FR-1/FR-4/FR-5 каждая новая задача с правкой `CHANGELOG.md` продолжает терять код
предшественников (уже потеряны 067, 069). Ложно-зелёный merge-verify подрывает само ядро
автономности конвейера.

View File

@@ -0,0 +1,129 @@
# 02 — ТЗ: ORCH-073 — системный фикс эрозии main + восстановление кода 067/069
> ТЗ описывает ТРЕБУЕМОЕ ПОВЕДЕНИЕ и точки изменения. Выбор конкретного дизайна
> (где именно резать docs-PR от code-PR, формат набора регресс-маркеров) — за архитектором (`06-adr`).
> Запрещено комментировать ТЗ задним числом: если требование не годится — вернуть в Анализ.
## 1. Задействованные модули `src/`
| Модуль | Роль в фиксе | FR |
| --- | --- | --- |
| `src/merge_gate.py` | `verify_merged_to_main`, `pr_already_merged`, `merge_pr`, новый регресс-гард | FR-1, FR-2, FR-3, FR-5 |
| `src/stage_engine.py` | `_handle_merge_verify` (под-гейт `deploy → done`) — точка вызова FR-1/FR-5 | FR-1, FR-5 |
| `src/config.py` | (опц.) настройки регресс-гарда: kill-switch + набор маркеров/таймаут | FR-5 |
| `.gitattributes` (корень репо, новый) | `CHANGELOG.md merge=union` (+ опц. `docs/*.md merge=union`) | FR-4 |
| `docs/architecture/README.md` | раздел merge-verify — обновить под новую семантику | AC-8 |
| `CHANGELOG.md` | запись Unreleased | AC-8 |
| `docs/work-items/ORCH-073/06-adr/` | ADR на новую семантику merge-verify + регресс-гард | AC-8 |
## 2. Требуемые изменения по коду
### FR-1 (G3, ядро) — `verify_merged_to_main` чинит семантику
**Текущее (баг):** `src/merge_gate.py::verify_merged_to_main(repo, branch, sha)` возвращает `True`,
если `pr_already_merged(...)` **ИЛИ** `git merge-base --is-ancestor <sha> origin/main`.
OR-ветка `pr_already_merged` засчитывает docs-PR → ложно-зелёный.
**Требование:** подтверждение merge — **ТОЛЬКО** прямой факт «deployed commit является предком
`origin/main`»:
- после `git fetch origin main` выполнить `git merge-base --is-ancestor <deployed_sha> origin/main`;
- `rc==0``True` (код в main), иначе → `False`.
- `pr_already_merged` **НЕ может быть единственным/достаточным** условием `True`. Допустимо
оставить PR-флаг только как **вспомогательный** сигнал (idempotency / диагностика), но он НЕ
должен подтверждать merge при отсутствии SHA в main.
- Пустой `sha` → неопределённо → `False` (fail-closed: alert + HOLD), как сейчас.
- never-raise: любая git/HTTP-ошибка → `False` (INV-1).
### FR-2 (G2) — `pr_already_merged` различает code-PR и docs-PR
**Текущее (баг):** `src/merge_gate.py::pr_already_merged` возвращает `True` за ЛЮБОЙ
`merged==True` PR из `GET /pulls?state=all&head=<branch>` — включая авто docs-PR.
**Требование (на выбор архитектора, предпочтителен вариант «б»):**
- **(а)** засчитывать merged только для PR, реально несущего код ветки: `base.ref==main`
И `head.ref==<feature-branch>` (исключить docs/* ветки и docs-only PR); **или**
- **(б, предпочтительно)** понизить роль `pr_already_merged` до **idempotency-guard**: единственный
критерий «merged/done» — SHA-предок-`main` (FR-1); PR-флаги вспомогательны.
- Поведение для non-self репо (enduro) не меняется (INV-5).
- never-raise → `False` (консервативно).
### FR-3 (G2) — `merge_pr` реально сливает code-ветку
**Требование:** `src/merge_gate.py::merge_pr` мержит ИМЕННО feature-PR с кодом (`base==main`,
`head==<feature-branch>`), а не полагается на docs-PR. После merge — обязательная верификация
по FR-1 (SHA в main) как единственный источник истины. Merge только через Gitea PR-merge API,
никогда push/force-push в `main` (INV-2).
### FR-5 (G3 регресс-гард, защита навсегда) — sanity-проверка целостности main
**Требование:** перед фиксацией `done``_handle_merge_verify`, ПОСЛЕ зелёного
`check_deploy_status`, до `update_task_stage`):
1. Подтвердить FR-1 (deployed SHA — предок `origin/main`).
2. (опц., по дизайну) Проверить, что в `origin/main` присутствует **набор маркеров** ключевых
функций недавно-merged задач (regression marker set) — merge не уменьшил его.
3. При откате соседнего кода / отсутствии маркера → **alert** «main regressed: code of <prev
tasks> missing» (Telegram + Plane), задача **НЕ `done`** (HOLD), как ветка not-merged в ORCH-071.
- Реакция — **ALERT-only + HOLD**, без авто-отката на `development` (это инфра-дефект, не код-фолт).
- never-raise (INV-1); kill-switch (как `merge_verify_enabled`); условность только для self-hosting
/ `merge_verify_repos` (INV-5).
- Набор маркеров — конфигурируемый/декларативный (например, в `src/config.py` или рядом), чтобы
следующие задачи могли его расширять. Точный формат — за архитектором.
### FR-4 (G2/G4 корень) — `.gitattributes` с `merge=union`
**Требование:** в корне репо завести `.gitattributes`:
```
CHANGELOG.md merge=union
# опционально для append-only документов:
# docs/**/*.md merge=union # ВНИМАНИЕ: union НЕ годится для файлов, где правки
# переписывают строки — применять только к append-only
```
- `merge=union` встроен в git (драйвер по умолчанию), доп. конфиг хоста не требуется — но
проверить, что атрибут реально применяется в worktree агентов (`git check-attr merge CHANGELOG.md`).
- Эффект: при `auto_rebase_onto_main` правки `## [Unreleased]` авто-сливаются (обе записи
сохраняются) без конфликта → ветка не откатывается в `development` и не затирает соседний код.
## 3. Изменения API
- **Внешних HTTP API оркестратора (`src/main.py` endpoints) НЕ менять.**
- Внутренние сигнатуры:
- `verify_merged_to_main(repo, branch, sha) -> bool` — семантика меняется, сигнатура сохраняется.
- `pr_already_merged(repo, branch) -> bool` — семантика/назначение уточняется.
- `merge_pr(repo, branch) -> tuple[bool, str]` — поведение уточняется (фильтр code-PR).
- (опц.) новая функция регресс-гарда в `merge_gate.py``tuple[bool, str]`/`bool`, never-raise.
- `GET /queue` `merge_verify_status()` — допустимо дополнить счётчиком регресс-алертов (read-only,
не источник истины).
- Внешние вызовы Gitea — те же эндпоинты (`/pulls`, `/pulls/{index}/merge`).
## 4. Изменения схемы БД
- **НЕТ.** Схема БД (`src/db.py`) не трогается (Не-цель). Регресс-гард опирается на git/`origin/main`,
не на новые таблицы.
## 5. Требования к новым/изменённым QG checks
- **Новых зарегистрированных QG-checks не вводить.** Логика остаётся **под-гейтом** в
`advance_stage` (`_handle_merge_verify`), как ORCH-071 — не новый элемент реестра `QG_CHECKS`.
- Реестр `QG_CHECKS`, `check_deploy_status`, `_parse_deploy_status`, merge-gate
(`check_branch_mergeable`), image-freshness — **без изменений**.
## 6. Конфигурация (`src/config.py` / `.env.example`)
- Существующие `merge_verify_enabled` (kill-switch, дефолт `true`), `merge_verify_repos` (пусто →
только self-hosting), `merge_pr_timeout_s`, `merge_verify_timeout_s` — переиспользовать.
- (опц., по дизайну) новые: kill-switch регресс-гарда и декларация набора маркеров. Дефолты —
безопасные (для non-self — no-op). Любой новый ключ задокументировать в `.env.example`.
## 7. Артефакты pipeline, которые должны быть созданы/обновлены
- `docs/work-items/ORCH-073/06-adr/ADR-001-*.md` — решение по новой семантике merge-verify
(FR-1/FR-2/FR-3) + регресс-гард (FR-5) + `.gitattributes` (FR-4).
- `docs/architecture/README.md` — обновить раздел «Merge-в-main + пост-деплой верификация»
(ORCH-071) под FR-1 (SHA как единственный критерий) и добавить регресс-гард FR-5.
- `CHANGELOG.md` — запись в `## [Unreleased]`.
- `docs/work-items/ORCH-073/10-tech-risks.md`, `12-review.md`, `13-test-report.md`,
`14-deploy-log.md`, `15-staging-log.md` — по ходу конвейера.
- `04-test-plan.yaml` (этот пакет) — реализовать тесты в `tests/`.
## 8. Аудит G4 (зафиксировать в ADR / 06-adr)
Зафиксировать подтверждённую причину docs-only merge: у feature-ветки 067/069 в `main` попадали
только авто docs-PR (staging-log / deploy-log / CLAUDE.md / CHANGELOG), а code-PR не сливался,
при этом `pr_already_merged` засчитывал docs-PR → merge-verify ложно `CONFIRMED``done`.
Корень устранён FR-1+FR-2+FR-3. Восстановление кода (G1) уже выполнено restore-PR #76
подтвердить маркеры в `origin/main` (AC-1).

View File

@@ -0,0 +1,77 @@
# 03 — Критерии приёмки: ORCH-073
Каждый критерий — однозначный PASS/FAIL. Reviewer/Tester проверяют буквально.
## AC-1 — Код 067/069/071 одновременно в main (G1)
`origin/main` содержит **одновременно**: `plane_issue_link` + кликабельный заголовок (ORCH-067),
`qg0_title_max` (ORCH-069), `verify_merged_to_main` (ORCH-071).
- **PASS:** все три маркера присутствуют, счётчики > 0:
`git grep -c plane_issue_link origin/main -- src/notifications.py` > 0;
`git grep -c qg0_title_max origin/main -- src/` > 0;
`git grep -c verify_merged_to_main origin/main -- src/merge_gate.py` > 0.
- **FAIL:** хотя бы один маркер == 0.
## AC-2 — `verify_merged_to_main` подтверждает merge ТОЛЬКО по SHA-в-main (FR-1)
`verify_merged_to_main(repo, branch, sha)` возвращает `True` **только** когда `sha` — реальный
предок `origin/main`.
- **PASS:** unit-тест: `sha` НЕ в `main``False`, **даже если** существует merged docs-PR той же
ветки (mock `pr_already_merged`/Gitea возвращает merged docs-PR). `sha` в `main``True`.
- **FAIL:** функция возвращает `True` при `sha` не в `main` из-за merged docs-PR.
## AC-3 — Воспроизведение исходного багаНЕ done + alert (FR-1/FR-2)
Задача с merged **docs-PR**, но БЕЗ merged **code-PR** (SHA не в main): merge-verify НЕ
`CONFIRMED`.
- **PASS:** `_handle_merge_verify` возвращает HOLD (intervened) → задача остаётся на `deploy`,
НЕ `done`, отправлен alert «not merged» (Telegram + Plane `set_issue_blocked`). Mock
воспроизводит сценарий ORCH-067/069.
- **FAIL:** задача доходит до `done` / нет alert.
## AC-4 — `.gitattributes CHANGELOG.md merge=union` (FR-4)
В корне репо есть `.gitattributes` с `CHANGELOG.md merge=union`.
- **PASS:** файл существует, `git check-attr merge CHANGELOG.md``merge: union`; тест: два
последовательных ребейза/слияния с правкой `## [Unreleased]` НЕ дают конфликта, обе записи
сохранены в результирующем `CHANGELOG.md`.
- **FAIL:** атрибут отсутствует/не применяется ИЛИ возникает конфликт-маркер при ребейзе.
## AC-5 — Регресс-гард ловит откат соседнего кода (FR-5)
После деплоя `main` без маркера ранее-merged задачи → alert, задача НЕ `done`.
- **PASS:** тест: симуляция `main`, где deployed SHA есть, но набор маркеров уменьшился (или
deployed SHA НЕ предок main) → `_handle_merge_verify` HOLD + alert «main regressed», НЕ `done`.
- **FAIL:** регресс соседнего кода не пойман, задача `done`.
## AC-6 — Happy-path без ложных alert (INV-5 / AC-5 ТЗ)
Код реально в `main` (deployed SHA — предок `origin/main`) → задача `done` штатно, без ложного
alert; для non-self репо (enduro) merge/verify без изменений.
- **PASS:** тест happy-path: SHA в main → `verify_merged_to_main`=`True`, `_handle_merge_verify`
возвращает «advance» (не intervened); non-self репо → под-гейт no-op.
- **FAIL:** ложный alert на корректном merge ИЛИ изменение поведения для enduro.
## AC-7 — Идемпотентность по SHA-в-main (INV-4)
Повторный прогон/reaper уже-слитой задачи (SHA в main) → no-op, без второго merge.
- **PASS:** тест: re-drive задачи с SHA-в-main → `merge_pr` no-op («already-merged»/idempotent),
второго Gitea POST merge нет; задача остаётся `done`.
- **FAIL:** второй merge / дубликат / ошибка.
## AC-8 — Документация и тесты обновлены (правило агентов §2/§6)
- **PASS:** обновлены `CHANGELOG.md` (Unreleased), `docs/architecture/README.md` (раздел
merge-verify под FR-1 + регресс-гард FR-5), создан ADR в `docs/work-items/ORCH-073/06-adr/`;
pytest зелёный (`pytest tests/ -q`).
- **FAIL:** доки/ADR не обновлены ИЛИ pytest красный.
## AC-9 — G4 аудит задокументирован
Причина docs-only merge (code-PR не слит, `pr_already_merged` засчитал docs-PR) зафиксирована в
ADR/06-adr, корень устранён (FR-1+FR-2+FR-3).
- **PASS:** ADR содержит раздел «Root cause / G4 audit» с воспроизведением и устранением.
- **FAIL:** аудит отсутствует.
## AC-10 — Воспроизведение на staging «исправлено навсегда» (G3/AC-9 ТЗ)
2 задачи, обе с правкой `CHANGELOG.md`, прогнаны через staging → обе доезжают в `main` без потери
кода друг друга.
- **PASS:** зафиксировано в `15-staging-log.md`: оба набора маркеров присутствуют в `main` после
обоих merge; ни одна правка CHANGELOG не вызвала конфликт/откат.
- **FAIL:** код одной задачи затёрт другой ИЛИ конфликт CHANGELOG.
## AC-11 — self-hosting safety сохранена (INV-2/INV-3)
- **PASS:** merge только через PR-API (без force-push в `main`); прод-контейнер не падал в рамках
задачи; ручной `Confirm Deploy` сохранён.
- **FAIL:** force-push в main / рестарт прод-контейнера в рамках merge / обход Confirm Deploy.

View File

@@ -0,0 +1,117 @@
work_item: ORCH-073
title: "CRIT: эрозия main — системный фикс merge-verify + восстановление кода 067/069"
notes: >
Покрытие FR-1..FR-5 / AC-1..AC-11. Все верификаторы — never-raise (INV-1):
при ошибке git/HTTP → False (fail-closed), не падение. Gitea/git вызовы мокаются
(monkeypatch httpx + subprocess), как в существующих тестах merge_gate/stage_engine.
Тесты регресс-гарда и .gitattributes используют временный git-репозиторий (tmp_path).
tests:
# ---- FR-1: verify_merged_to_main — SHA-в-main как единственный критерий ----
- id: TC-01
type: unit
description: "verify_merged_to_main: sha — предок origin/main → True (happy-path, AC-6)."
module: tests/test_orch073_merge_verify.py
expected: PASS
- id: TC-02
type: unit
description: "verify_merged_to_main: sha НЕ предок main И существует merged docs-PR ветки → False (баг 067/069, AC-2)."
module: tests/test_orch073_merge_verify.py
expected: PASS
- id: TC-03
type: unit
description: "verify_merged_to_main: пустой sha → False (неопределённо, fail-closed)."
module: tests/test_orch073_merge_verify.py
expected: PASS
- id: TC-04
type: unit
description: "verify_merged_to_main: git fetch/merge-base бросает исключение → False (never-raise, INV-1)."
module: tests/test_orch073_merge_verify.py
expected: PASS
# ---- FR-2: pr_already_merged различает code-PR / docs-PR ----
- id: TC-05
type: unit
description: "pr_already_merged/идентификация PR: merged docs-PR (head=docs/*, base=main) НЕ засчитывается как merge кода ветки."
module: tests/test_orch073_pr_classify.py
expected: PASS
- id: TC-06
type: unit
description: "merged code-PR (head=<feature-branch>, base=main) корректно распознаётся как code-merge."
module: tests/test_orch073_pr_classify.py
expected: PASS
- id: TC-07
type: unit
description: "pr_already_merged: HTTP-ошибка/не-200 → False (never-raise, консервативно)."
module: tests/test_orch073_pr_classify.py
expected: PASS
# ---- FR-3: merge_pr сливает именно code-ветку ----
- id: TC-08
type: unit
description: "merge_pr выбирает open PR с head==<feature-branch> и base==main (не docs/*), вызывает Gitea POST merge."
module: tests/test_orch073_merge_pr.py
expected: PASS
- id: TC-09
type: unit
description: "merge_pr: нет open code-PR → (False, 'no open PR'); никогда не push/force-push main (INV-2)."
module: tests/test_orch073_merge_pr.py
expected: PASS
- id: TC-10
type: unit
description: "merge_pr идемпотентен: уже-слитый code-PR (SHA в main) → no-op, без второго POST merge (AC-7/INV-4)."
module: tests/test_orch073_merge_pr.py
expected: PASS
# ---- FR-4: .gitattributes CHANGELOG.md merge=union ----
- id: TC-11
type: integration
description: ".gitattributes в корне репо содержит 'CHANGELOG.md merge=union'; git check-attr подтверждает driver=union (AC-4)."
module: tests/test_orch073_gitattributes.py
expected: PASS
- id: TC-12
type: integration
description: "Во временном git-репо два ребейза/слияния с правкой '## [Unreleased]' НЕ дают конфликта; обе записи в CHANGELOG сохранены (AC-4)."
module: tests/test_orch073_gitattributes.py
expected: PASS
# ---- FR-5: регресс-гард целостности main + интеграция в _handle_merge_verify ----
- id: TC-13
type: unit
description: "_handle_merge_verify: SHA в main И маркеры на месте → return False (advance к done, happy-path AC-6)."
module: tests/test_orch073_regression_guard.py
expected: PASS
- id: TC-14
type: unit
description: "_handle_merge_verify: SHA НЕ в main (docs-only merge) → return True (HOLD), alert + set_issue_blocked, НЕ done (AC-3)."
module: tests/test_orch073_regression_guard.py
expected: PASS
- id: TC-15
type: unit
description: "Регресс-гард: deployed SHA есть, но набор маркеров ранее-merged задач уменьшился → HOLD + alert 'main regressed', НЕ done (AC-5)."
module: tests/test_orch073_regression_guard.py
expected: PASS
- id: TC-16
type: unit
description: "_handle_merge_verify: внутренняя ошибка верификатора → HOLD + alert, без проброса исключения в advance_stage (never-raise, INV-1)."
module: tests/test_orch073_regression_guard.py
expected: PASS
# ---- Условность / обратная совместимость ----
- id: TC-17
type: unit
description: "merge_verify_applies: non-self репо (enduro) или kill-switch off → под-гейт no-op, поведение merge/verify без изменений (AC-6/INV-5)."
module: tests/test_orch073_conditionality.py
expected: PASS
- id: TC-18
type: unit
description: "Регресс-гард уважает kill-switch (merge_verify_enabled=False) → no-op; для non-self → no-op (INV-5)."
module: tests/test_orch073_conditionality.py
expected: PASS
# ---- Регресс существующего поведения ----
- id: TC-19
type: integration
description: "Существующие тесты merge_gate/stage_engine (ORCH-065/071) остаются зелёными; полный pytest tests/ -q green (AC-8)."
module: tests/
expected: PASS

View File

@@ -0,0 +1,214 @@
# ADR-001 (ORCH-073): SHA-в-main как единственный критерий merge-verify + регресс-гард + `.gitattributes`
- **Статус:** Accepted
- **Дата:** 2026-06-08
- **Задача:** ORCH-073 (BUG CRITICAL — эрозия `main`)
- **Связь:** усиливает/чинит ORCH-071 (merge-verify под-гейт). Сквозной аналог — `docs/architecture/adr/adr-0014-merge-verify-sha-source-of-truth.md` (amends adr-0013).
- **Источники:** `01-brd.md` (root-cause git-аудит 08.06), `02-trz.md` (FR-1…FR-5), `03-acceptance-criteria.md` (AC-1…AC-11).
## Контекст
Код «задеплоенных» и переведённых в `done` задач **ORCH-067** (`plane_issue_link`, кликабельные
ссылки, tracker bump) и **ORCH-069** (`qg0_title_max`) физически отсутствовал в `origin/main`,
хотя обе прошли весь конвейер, Confirm Deploy, merge-verify `CONFIRMED` и стали `done`. В `main`
попадали только их **docs-коммиты** (staging/deploy-логи через отдельные авто docs-PR), но НЕ
код feature-веток. Внешнее проявление (нашёл Слава, 08.06): в карточке Telegram сырой номер
задачи вместо кликабельной ссылки — код ссылок есть в ветке ORCH-067, но не в `main`.
### Root cause (G4 audit) — подтверждён git-аудитом, НЕ гипотеза
1. **`verify_merged_to_main` подтверждает merge по ложному признаку.** Возвращает `True`, если
`pr_already_merged(repo, branch)` **ЛИБО** `git merge-base --is-ancestor <sha> origin/main`.
OR-ветка `pr_already_merged` — и есть дыра.
2. **`pr_already_merged` засчитывает ЛЮБОЙ merged PR.** `GET /pulls?state=all&head=<branch>` и
`True`, если **хоть один** PR `merged==True`. Параметр `head` у Gitea для одиночной строки-ветки
фильтрует ненадёжно → в выборку попадают авто docs-PR (staging/deploy-логи) с других веток
(`docs/*`). Сливается docs-PR → `pr_already_merged`=`True``verify_merged_to_main`=`True`
merge-verify `CONFIRMED``done`, хотя **code-PR НЕ слит**. Ложно-зелёный.
3. **CHANGELOG-ребейзы — вторичный усилитель.** `auto_rebase_onto_main` при конфликте
`CHANGELOG.md` откатывает `deploy-staging → development`; повторный ребейз ветки от старого
`main` несёт устаревшие версии соседних файлов, которые при merge тихо затирают код-сосед
(фантом-эффект как в ORCH-071, без конфликт-маркеров).
**G1 (восстановление кода) выполнено вручную** restore-PR #76`git grep` подтверждает в
`origin/main` одновременно `plane_issue_link` (8), `qg0_title_max` (3+2), `verify_merged_to_main`
(4). ORCH-073 фиксирует это в AC-1 и устраняет корень навсегда (FR-1…FR-5).
## Решение
Меняется **семантика merge-verify** (под-гейт ребра `deploy → done`, врезка `_handle_merge_verify`
в `advance_stage`, введён ORCH-071). `STAGE_TRANSITIONS`, реестр `QG_CHECKS`,
`check_deploy_status`/`_parse_deploy_status`, merge-gate (`check_branch_mergeable`),
image-freshness, схема БД (`src/db.py`) — **НЕ меняются**. Внешние HTTP-эндпоинты `src/main.py`
**НЕ меняются**.
### Р-1 (FR-1, ядро) — `verify_merged_to_main`: SHA-в-main — единственный критерий
Подтверждение merge — **ТОЛЬКО** прямой факт «deployed commit является предком `origin/main`»:
```
verify_merged_to_main(repo, branch, sha) -> bool:
if not sha: # пустой SHA -> неопределённо
log warning; return False # fail-closed (alert + HOLD)
git fetch origin main (timeout merge_verify_timeout_s)
rc = git merge-base --is-ancestor <sha> origin/main
return rc == 0
```
- **OR-ветка `pr_already_merged` удаляется** из `verify_merged_to_main`. PR-флаг больше **не
подтверждает** merge.
- Пустой `sha``False` (fail-closed: alert + HOLD), как сейчас.
- never-raise: любая git-ошибка → `False` (INV-1) — фейл-клозед для `done`.
> Дизайн-выбор: вариант (б) из ТЗ §2 FR-2 — единственный источник истины «merged/done» — это
> SHA-в-main. PR-флаги остаются только как **idempotency-guard** в `merge_pr` (Р-3), не как
> подтверждение.
### Р-2 (FR-2/G2) — `pr_already_merged`: различает code-PR и docs-PR
`pr_already_merged` понижается до **idempotency-guard для `merge_pr`** (не источник истины для
`done`). Но guard обязан быть **корректным**: «слит ли именно code-PR ЭТОЙ ветки», иначе merged
docs-PR заставил бы `merge_pr` ошибочно сделать no-op и пропустить реальный merge кода.
Поэтому в цикле явный фильтр (НЕ полагаться на ненадёжный query-параметр `head`):
```
for pr in resp.json():
if pr.merged is True
and pr.head.ref == branch # код именно этой feature-ветки
and pr.base.ref == "main": # таргет — main, не docs-база
return True
return False
```
- Исключает авто docs-PR (другой `head.ref`, напр. `docs/*`) и PR на не-`main` базу.
- never-raise → `False` (консервативно).
- Поведение для non-self репо (enduro) не меняется (INV-5) — `merge_pr`/verify для них как раньше.
### Р-3 (FR-3/G2) — `merge_pr`: сливает именно code-ветку
`merge_pr` уже выбирает открытый PR по `head.ref==branch`; добавляется фильтр `base.ref=="main"`
при выборе PR (защита от слияния PR на чужую базу). Idempotency-guard `pr_already_merged` (Р-2,
теперь корректный) перед merge оставляем — повторный прогон не делает второй POST. Merge —
ТОЛЬКО Gitea `POST /pulls/{index}/merge`, никогда push/force-push в `main` (INV-2). После merge
единственный источник истины «слилось» — FR-1 (SHA-в-main), его проверяет `_handle_merge_verify`.
### Р-4 (FR-5/G5) — регресс-гард целостности `main` (защита навсегда)
Новая детерминированная (no-LLM) функция в `merge_gate.py`, вызывается в `_handle_merge_verify`
**ПОСЛЕ** подтверждённого SHA-в-main (Р-1) и **ДО** `update_task_stage(done)`:
```
check_main_regression(repo, branch) -> tuple[bool, str]
# ok=True -> регресса нет (набор маркеров цел) -> пропустить к done
# ok=False -> маркер отсутствует -> "main regressed: <task/marker> missing"
```
**Декларативный набор маркеров** — константа в `merge_gate.py` (append-only, расширяется каждой
будущей задачей; НЕ БД, НЕ Plane — Не-цель):
```python
MAIN_REGRESSION_MARKERS = [
# (task, marker_substring, path)
("ORCH-067", "plane_issue_link", "src/notifications.py"),
("ORCH-069", "qg0_title_max", "src/config.py"),
("ORCH-071", "verify_merged_to_main", "src/merge_gate.py"),
("ORCH-073", "check_main_regression", "src/merge_gate.py"),
]
```
Проверка (в worktree после `git fetch origin main`): для каждого маркера
`git grep -c <marker> origin/main -- <path>`; счётчик `0`регресс.
- **Реакция при регрессе: ALERT-only + HOLD** (`set_issue_blocked` + Telegram + Plane-коммент
«main regressed: code of `<task>` missing»), задача **НЕ `done`**, остаётся на `deploy`. БЕЗ
авто-отката на `development` (это инфра-дефект, не код-фолт), симметрично not-merged ветке
ORCH-071.
- **Fail-OPEN на инфра-ошибке грепа** (намеренный trade-off): любая git/OS-ошибка самого грепа →
`(True, "guard inconclusive: …")`НЕ блокировать `done`. Обоснование: первичный фейл-клозед
гейт — это SHA-в-main (Р-1); вторичный marker-grep не должен давать ложный HOLD на git-сбое.
«Регресс» утверждается только при **детерминированном `count==0`**, не при «не смог определить».
- never-raise (INV-1). Kill-switch — новый `regression_guard_enabled` (дефолт `true`,
переиспользует область self-hosting через `merge_verify_applies`). Non-self репо — no-op (INV-5).
### Р-5 (FR-4/G4 корень) — `.gitattributes` с `merge=union`
В корне репозитория новый файл `.gitattributes`:
```
CHANGELOG.md merge=union
```
- `merge=union` — встроенный git-драйвер, доп. конфиг хоста не требуется; проверяется
`git check-attr merge CHANGELOG.md``merge: union`.
- Эффект: при `auto_rebase_onto_main` правки `## [Unreleased]` авто-сливаются (обе записи
сохраняются) без конфликт-маркера → ветка не откатывается в `development` и не тащит устаревшие
версии соседних файлов.
- **Решено НЕ добавлять `docs/**/*.md merge=union`:** union годится только для строго
append-only файлов; docs-артефакты (README, ADR, internals) регулярно **переписываются**
построчно — union там тихо задублировал бы строки. Ограничиваемся `CHANGELOG.md`.
- Оговорка о самозагрузке: задача, ВПЕРВЫЕ вносящая `.gitattributes`, при собственном ребейзе
ещё не получает эффект union (атрибут попадёт в `main` только после её merge). Это допустимо —
гард действует для всех последующих задач.
## Конфигурация
| Ключ | Дефолт | Назначение |
|---|---|---|
| `merge_verify_enabled` (есть) | `true` | kill-switch всего под-гейта |
| `merge_verify_repos` (есть) | `""` | CSV; пусто → только self-hosting |
| `merge_pr_timeout_s` / `merge_verify_timeout_s` (есть) | `60` | таймауты Gitea/git |
| `regression_guard_enabled` (новый) | `true` | kill-switch регресс-гарда (Р-4); non-self → no-op |
Новый ключ задокументировать в `.env.example`. Дефолты безопасны (для non-self — no-op).
## Сигнатуры (внутренние; внешний API не меняется)
- `verify_merged_to_main(repo, branch, sha) -> bool` — семантика меняется (Р-1), сигнатура та же.
- `pr_already_merged(repo, branch) -> bool` — назначение/фильтр уточняются (Р-2), сигнатура та же.
- `merge_pr(repo, branch) -> tuple[bool, str]` — фильтр `base==main` (Р-3), сигнатура та же.
- `check_main_regression(repo, branch) -> tuple[bool, str]`**новая**, never-raise, fail-open.
- `merge_verify_status()` — допустимо дополнить счётчиком регресс-алертов (read-only, не источник истины).
## Инварианты
- **INV-1** never-raise: ошибка верификации → alert/HOLD, не падение конвейера.
- **INV-2** self-hosting safety: прод 8500 не падает/не рестартится в рамках merge; merge только
Gitea PR-API, без force-push в `main`.
- **INV-3** ручной `Confirm Deploy` (ORCH-059) сохранён.
- **INV-4** идемпотентность опирается на «SHA-в-main», а не на «любой merged PR».
- **INV-5** обратная совместимость non-self (enduro): merge/verify/регресс-гард — no-op.
## Альтернативы (отклонены)
1. **Оставить `pr_already_merged` как со-критерий verify, но фильтровать по `head/base`**
отклонено: PR-флаг всё равно слабее факта «SHA в main» (PR можно слить и тут же откатить
ребейзом-соседом). Единственный надёжный критерий — предок-`main`. PR-флаг → только idempotency.
2. **`docs/**/*.md merge=union`** — отклонено (см. Р-5): тихая дубликация строк в переписываемых
доках.
3. **Регресс-гард с авто-откатом на `development`** — отклонено: регресс соседнего кода —
инфра-дефект merge, не код-фолт текущей задачи; реакция ALERT-only + HOLD (как ORCH-021/071).
4. **Хранить набор маркеров в БД/Plane** — отклонено (Не-цель «не менять схему БД/Plane»);
декларативная append-only константа в коде проще и версионируется вместе с фиксом.
5. **Fail-closed на marker-grep** — отклонено: дало бы ложный HOLD при git-сбое; первичный
фейл-клозед — SHA-в-main (Р-1), marker-grep вторичен → fail-open.
## Последствия
- **Плюс:** невозможно «`done` + прод задеплоен, а code-PR не в `main`» — единственный критерий
`done` теперь «SHA-в-main». Ложно-зелёный по docs-PR устранён в корне (Р-1+Р-2+Р-3).
- **Плюс:** CHANGELOG-конфликты больше не откатывают ветку и не тащат устаревший код-сосед (Р-5).
- **Плюс:** регресс-гард ловит откат соседнего кода даже если SHA-в-main прошёл (Р-4).
- **Минус:** при недоступной Gitea/git verify консервативно `False` → возможен ложный HOLD+alert
(снимается повтором; fail-closed для `done` приоритетен). Регресс-гард при git-сбое наоборот
fail-open (не блокирует) — осознанный trade-off, SHA-в-main остаётся первичным гейтом.
- **Минус:** набор маркеров требует дисциплины — каждая значимая задача дописывает свой маркер
(иначе гард его не защитит). Документируется в `CLAUDE.md`/README.
## Связи
- Amends: `docs/architecture/adr/adr-0013-merge-verify-gate.md` (ORCH-071) — меняет критерий verify.
- Сквозной: `docs/architecture/adr/adr-0014-merge-verify-sha-source-of-truth.md`.
- Постмортем: `docs/history/LESSONS_2026-06-08_phantom-merge.md`, runbook
`docs/operations/PHANTOM_MERGE_RUNBOOK.md`.
- AC: AC-1 (G1 markers), AC-2/AC-3 (Р-1/Р-2), AC-4 (Р-5), AC-5 (Р-4), AC-6 (happy-path),
AC-7 (idempotency), AC-8/AC-9 (docs+audit), AC-10 (staging), AC-11 (self-hosting safety).

View File

@@ -0,0 +1,32 @@
# 07 — Инфра-требования: ORCH-073
## Топология
**Без изменений.** Один сервер (mva154), prod `orchestrator` (8500), staging
`orchestrator-staging` (8501), общая SQLite, общая очередь. Новых контейнеров/портов/сервисов нет.
## Git / worktree
- Новый корневой файл **`.gitattributes`** (`CHANGELOG.md merge=union`). Драйвер `union`
встроенный в git, **доп. конфигурация хоста НЕ требуется**.
- Проверка применения в worktree агентов: `git check-attr merge CHANGELOG.md``merge: union`.
Атрибут действует при 3-way merge/rebase, когда `.gitattributes` присутствует в дереве
(`auto_rebase_onto_main` выполняет `git rebase origin/main` в per-branch worktree).
- Самозагрузка: первая задача с `.gitattributes` своего ребейза не ускоряет (атрибут попадёт в
`main` после её merge); эффект — для последующих задач. Допустимо.
- Регресс-гард (`check_main_regression`) использует уже существующий per-branch worktree
(`ensure_worktree` + `git fetch origin main` + `git grep origin/main`). Новых клонов/worktree нет.
## Сеть / внешние интеграции
- Те же Gitea-эндпоинты: `GET /pulls`, `POST /pulls/{index}/merge`. Новых внешних вызовов нет.
- Telegram/Plane — существующие хелперы alert (`send_telegram`, `set_issue_blocked`,
`plane_add_comment`). Новых интеграций нет.
## Деплой self (self-hosting safety)
- Прод-контейнер `orchestrator` (8500) **НЕ рестартить/не ронять** в рамках задачи.
- Обязательный staging-гейт (8501) перед прод-деплоем; прод-деплой — только переводом на
`Confirm Deploy` (ORCH-059). Ручной гейт не меняется.
- Merge — только Gitea PR-API, без force-push в `main`.
## Конфигурация (хост `.env` / `.env.example`)
- Новый ключ `regression_guard_enabled` (дефолт `true`) — задокументировать в `.env.example`.
- Существующие `merge_verify_enabled`/`merge_verify_repos`/`merge_pr_timeout_s`/
`merge_verify_timeout_s` — переиспользуются, без изменений значений.

View File

@@ -0,0 +1,23 @@
# 08 — Требования к данным/схеме БД: ORCH-073
## Схема БД
**Без изменений.** `src/db.py` не трогается (Не-цель BRD §5, ТЗ §4). Новых таблиц/колонок/
миграций нет.
## Источник истины merge-verify
- Подтверждение `done` опирается **только на git** (`origin/main`: `git merge-base
--is-ancestor <sha> origin/main`), НЕ на состояние БД и НЕ на Plane-статусы.
- Регресс-гард (`check_main_regression`) опирается на `git grep origin/main` по декларативному
набору маркеров — **не на БД**.
- Набор маркеров `MAIN_REGRESSION_MARKERS` — **append-only константа в коде** (`src/merge_gate.py`),
версионируется вместе с фиксом. Сознательно НЕ в БД и НЕ в Plane (Не-цель).
## Состояние в БД (читается, не меняется)
- `tasks.stage` — переходы через существующий `update_task_stage`/`advance_stage`; HOLD = задача
остаётся на `deploy` (не записывается `done`). Семантика та же, что у ORCH-071.
- Счётчики `_MERGE_VERIFY_COUNTERS` — **in-process**, не БД; read-only через `GET /queue`.
Допустимо дополнить счётчиком регресс-алертов (наблюдаемость, не источник истины).
## Plane
**Без изменений** (Не-цель). Используются существующие сеттеры (`set_issue_blocked`,
`plane_add_comment`) для alert/HOLD. Новых статусов/маппингов нет.

View File

@@ -0,0 +1,19 @@
# 10 — Технические риски: ORCH-073
| # | Риск | Вероятность | Влияние | Митигация |
|---|------|-------------|---------|-----------|
| R-1 | **Ложный HOLD на сбое Gitea/git** — verify консервативно `False` при недоступности → задача не доходит до `done`, нужен повтор. | средняя | среднее | Осознанный fail-closed для `done` (приоритет: не дать ложно-зелёный). Снимается re-drive (reaper/reconciler/re-approve). Документировано в ADR «Последствия». |
| R-2 | **`pr_already_merged` всё ещё ловит docs-PR** при иной структуре head/base в Gitea (cross-repo `owner:branch`). | низкая | высокое (возврат бага) | Явный фильтр в цикле `head.ref==branch И base.ref=="main"` (не полагаться на query-param). Тест AC-2/AC-3 мокает merged docs-PR и проверяет, что verify=`False`. |
| R-3 | **Регресс-гард fail-open пропустит реальный регресс** во время git-сбоя грепа. | низкая | среднее | Первичный гейт `done` — SHA-в-main (fail-closed). Marker-grep вторичен; «регресс» — только при детерминированном `count==0`. Trade-off зафиксирован в ADR. |
| R-4 | **Набор маркеров устаревает/неполный** — будущая задача не добавила свой маркер → гард её не защищает. | средняя | среднее | Append-only константа в коде + правило в `CLAUDE.md`/README «значимая задача дописывает маркер». Reviewer проверяет. Не регресс существующего поведения (только недозащита нового). |
| R-5 | **`merge=union` тихо дублирует строки** при применении к не-append-only файлам. | низкая | среднее | Union строго ограничен `CHANGELOG.md`; `docs/**` под union НЕ ставится (решение Р-5 ADR). |
| R-6 | **Самозагрузка `.gitattributes`** — первая задача не получает эффект union на своём ребейзе. | высокая (одноразово) | низкое | Принято: атрибут попадёт в `main` после merge ORCH-073, действует для последующих задач. Для самой ORCH-073 CHANGELOG-конфликт разрешается вручную при необходимости. |
| R-7 | **Ложный «main regressed» при легитимном рефакторе**, переименовавшем маркер-функцию. | низкая | среднее | Маркеры выбираются как стабильные публичные имена; при намеренном переименовании задача обновляет `MAIN_REGRESSION_MARKERS` в том же PR (правило документации). |
| R-8 | **Регресс на non-self репо (enduro)** из-за нового кода. | низкая | высокое | Вся врезка под `merge_verify_applies` (kill-switch + self-hosting scope); регресс-гард — отдельный `regression_guard_enabled`; non-self → no-op (INV-5). Тест AC-6 (enduro no-op). |
| R-9 | **Self-hosting: рестарт/падение прода** при ошибке в merge_gate. | низкая | высокое (групповой риск) | never-raise контракт (INV-1); merge только PR-API без force-push; staging-гейт обязателен; прод не рестартится в рамках merge. Тест AC-11. |
## Сводный вывод
Изменения локализованы в `src/merge_gate.py` + врезка в `_handle_merge_verify`
(`src/stage_engine.py`) + новый ключ конфигурации + корневой `.gitattributes`. Схема БД, Plane,
внешние HTTP-эндпоинты, реестр QG, `STAGE_TRANSITIONS` — не затронуты. Главный остаточный риск —
ложный HOLD на инфра-сбое (R-1), сознательно принят ради устранения ложно-зелёного merge-verify.

View File

@@ -0,0 +1,75 @@
---
type: review
work_item_id: ORCH-073
verdict: APPROVED
version: 1
---
# Review ORCH-073
## Summary
Системный фикс эрозии `main` (фантомный merge ORCH-067/069) реализован строго по
ТЗ (FR-1…FR-5) и ADR-001. Все 11 критериев приёмки выполнены, документация обновлена
в том же PR, `pytest tests/ -q`**941 passed**. Self-hosting-инварианты соблюдены
(merge только через Gitea PR-API, без force-push в `main`; non-self репо — no-op).
Блокирующих и must-fix замечаний нет.
## Проверка по осям
### 1. Соответствие ТЗ (02-trz.md)
- **FR-1** — `verify_merged_to_main` подтверждает merge ТОЛЬКО `git merge-base --is-ancestor <sha> origin/main`; OR-ветка `pr_already_merged` удалена; пустой SHA / git-ошибка → `False` (fail-closed, never-raise). ✓
- **FR-2** — `pr_already_merged` понижен до idempotency-guard, явный in-loop фильтр `merged & head.ref==branch & base.ref=="main"` (не ненадёжный query `head`). ✓
- **FR-3** — `merge_pr` выбирает open PR по `head.ref==branch` И `base.ref=="main"`; merge только `POST /pulls/{n}/merge`. ✓
- **FR-4** — корневой `.gitattributes` с `CHANGELOG.md merge=union`; `docs/**` намеренно НЕ включён. ✓
- **FR-5** — `check_main_regression` (детерминированный, no-LLM) + декларативный append-only `MAIN_REGRESSION_MARKERS`; вызов в `_handle_merge_verify` ПОСЛЕ SHA-в-main и ДО `done`; ALERT-only + HOLD; fail-open на git-ошибке грепа; kill-switch `regression_guard_enabled`. ✓
### 2. Соответствие ADR (06-adr/ADR-001 + adr-0014)
Реализация 1:1 соответствует Р-1…Р-5. G4-аудит и root-cause зафиксированы в ADR
(раздел «Root cause (G4 audit)»). Сквозной ADR-0014 заведён, `adr/README.md` обновлён,
`adr-0013` помечен как amended. Нарушений глобальных ADR не обнаружено.
**AC-1 подтверждён в `origin/main`:** `plane_issue_link`(8), `qg0_title_max`(config.py 3),
`verify_merged_to_main`(4). **AC-4 подтверждён:** `git check-attr merge CHANGELOG.md → merge: union`.
### 3. Качество кода
- Строгий never-raise на всех публичных функциях merge_gate; INV-1…INV-5 соблюдены.
- Docstrings содержательные, со ссылками на FR/AC/INV; обоснован осознанный trade-off
fail-open для marker-grep против fail-closed SHA-в-main.
- `_hold_main_regressed` симметричен not-merged-HOLD; уведомления Plane/Telegram best-effort,
не ломают HOLD.
- Схема БД, реестр `QG_CHECKS`, `STAGE_TRANSITIONS`, внешние HTTP-эндпоинты — не тронуты (как и заявлено).
### 4. Качество тестов
18 тест-кейсов (TC-01…18) в 6 файлах `tests/test_orch073_*.py`, не тривиальные:
- TC-02 воспроизводит исходный баг (merged docs-PR не подтверждает merge), проверяет, что
PR-флаг verify-ом более не запрашивается.
- TC-14/15 различают HOLD по «not-merged» и по «main-regressed».
- TC-10 — идемпотентность (нет второго POST merge). TC-17/18 — conditionality/kill-switch.
- TC-12 в throwaway-репо реально проверяет union-merge без конфликта.
## Findings
### P0 — Blocker
- нет
### P1 — Must fix
- нет
### P2 — Should fix
- нет
### P3 — Nice-to-have
- Маркер `("ORCH-073", "check_main_regression", "src/merge_gate.py")` самозагрузочный
(попадёт в `origin/main` только после merge этой задачи) — поведение корректное и
оговорено в ADR (self-bootstrap), замечание чисто информационное.
## Документация
Полностью обновлена в этом же PR (правило агентов §2/§6, AC-8):
- `docs/architecture/README.md` — раздел merge-verify переписан под FR-1 + добавлены регресс-гард (FR-5) и `.gitattributes` (FR-4).
- `CHANGELOG.md` — запись в `## [Unreleased]`.
- `docs/work-items/ORCH-073/06-adr/ADR-001-*.md` — новый ADR с G4-аудитом; `docs/architecture/adr/adr-0014-*.md` — сквозной ADR; `adr/README.md` обновлён.
- `.env.example` — задокументирован новый ключ `ORCH_REGRESSION_GUARD_ENABLED` + блок merge-verify.
Требование «изменён `src/` → обновлена документация» выполнено. Блокеров по документации нет.
## Вердикт
**APPROVED** — нет P0/P1; код, тесты и документация соответствуют ТЗ/ADR; self-hosting-страховки сохранены.

View File

@@ -0,0 +1,83 @@
---
type: test-report
work_item_id: ORCH-073
result: PASS
---
# Test Report — ORCH-073
CRIT: системный фикс эрозии `main` (фантомный merge ORCH-067/069) + восстановление кода.
## Окружение
- Python: 3.12.13
- pytest: 8.3.3
- Дата: 2026-06-08
- Worktree: `feature/ORCH-073-crit-main-orch-067-069`
- Prod health (8500): `{"status":"ok","service":"orchestrator"}` — контейнер не тронут
## Smoke-тесты API (prod 8500, read-only)
| Endpoint | Результат |
|----------|-----------|
| `GET /health` | `{"status":"ok"}` — PASS |
| `GET /status` | active_tasks отдаётся, ORCH-073 на стадии `testing` — PASS |
| `GET /queue` | counts/reconcile/reaper/post_deploy снимок отдаётся, breaker `closed` — PASS |
## Результаты по тест-плану (04-test-plan.yaml)
| TC ID | Описание | Тест-функция | Результат |
|-------|----------|--------------|-----------|
| TC-01 | verify_merged_to_main: sha — предок main → True (AC-6) | test_tc01_true_when_sha_is_ancestor | PASS |
| TC-02 | sha НЕ в main + merged docs-PR → False (баг 067/069, AC-2) | test_tc02_false_when_sha_not_in_main_even_with_merged_docs_pr | PASS |
| TC-03 | пустой sha → False (fail-closed) | test_tc03_empty_sha_is_false | PASS |
| TC-04 | git error → False (never-raise, INV-1) | test_tc04_never_raises_on_git_error / _worktree_error | PASS |
| TC-05 | merged docs-PR не засчитан как code-merge (FR-2) | test_tc05_merged_docs_pr_not_counted | PASS |
| TC-06 | merged code-PR распознан (base=main, head=branch) | test_tc06_merged_code_pr_recognised / _onto_non_main_base_not_counted | PASS |
| TC-07 | HTTP-ошибка/не-200 → False (never-raise) | test_tc07_non_200_is_false / _http_exception_is_false | PASS |
| TC-08 | merge_pr выбирает code-PR, не docs/* (FR-3) | test_tc08_merges_code_pr_not_docs_pr / _skips_pr_onto_non_main_base | PASS |
| TC-09 | нет open code-PR → (False,...), без push main (INV-2) | test_tc09_no_open_pr_no_shell_out | PASS |
| TC-10 | merge_pr идемпотентен, без второго POST (AC-7/INV-4) | test_tc10_idempotent_already_merged | PASS |
| TC-11 | .gitattributes: CHANGELOG.md merge=union (AC-4) | test_tc11_gitattributes_declares_union | PASS |
| TC-12 | union-merge сохраняет обе записи Unreleased без конфликта | test_tc12_union_merge_keeps_both_entries | PASS |
| TC-13 | _handle_merge_verify: SHA в main + маркеры → advance (AC-6) | test_tc13_confirmed_and_intact_advances | PASS |
| TC-14 | docs-only merge → HOLD + alert, НЕ done (AC-3) | test_tc14_sha_not_in_main_holds | PASS |
| TC-15 | регресс-гард: маркер ранее-merged задачи пропал → HOLD + alert (AC-5) | test_tc15_marker_missing_holds | PASS |
| TC-16 | внутр. ошибка верификатора → HOLD + alert, never-raise (INV-1) | test_tc16_internal_error_holds_never_raises | PASS |
| TC-17 | conditionality: non-self/kill-switch → под-гейт no-op (AC-6/INV-5) | test_tc17_merge_verify_applies_scope / _under_gate_noop_for_non_self | PASS |
| TC-18 | регресс-гард уважает kill-switch / non-self → no-op (INV-5) | test_tc18_guard_kill_switch_skips_guard / _guard_noop_for_non_self_repo | PASS |
| TC-19 | полный pytest tests/ -q зелёный (AC-8) | весь набор tests/ | PASS |
Все 19 TC из тест-плана покрыты (24 тест-функции в 6 файлах `tests/test_orch073_*.py`).
## Проверка критериев приёмки (03-acceptance-criteria.md)
| AC | Проверка | Результат |
|----|----------|-----------|
| AC-1 | Маркеры в origin/main: plane_issue_link=8, qg0_title_max=3, verify_merged_to_main=4 (все >0) | PASS |
| AC-2 | TC-02: sha не в main + merged docs-PR → False | PASS |
| AC-3 | TC-14: docs-only merge → HOLD + alert, НЕ done | PASS |
| AC-4 | `git check-attr merge CHANGELOG.md``merge: union`; TC-11/12 | PASS |
| AC-5 | TC-15: уменьшение набора маркеров → HOLD + alert «main regressed» | PASS |
| AC-6 | TC-01/13/17: happy-path done без ложного alert; enduro no-op | PASS |
| AC-7 | TC-10: re-drive слитой задачи → no-op, без второго merge | PASS |
| AC-8 | 941 passed; доки/ADR/CHANGELOG обновлены (см. 12-review) | PASS |
| AC-9 | G4-аудит в ADR-001 (root cause docs-only merge) — подтверждён reviewer | PASS |
| AC-10 | staging-проверка — стадия deploy-staging (вне scope tester) | — |
| AC-11 | merge только PR-API; прод-контейнер не падал в рамках тестов | PASS |
## Вывод pytest
```
tests/ -q --tb=short:
........................................................................ [100%]
941 passed, 1 warning in 25.37s
tests/test_orch073_*.py -v:
24 passed, 1 warning in 0.54s
```
(1 warning — PydanticDeprecatedSince20 в src/config.py, не относится к ORCH-073, не блокирует.)
## Итог
**PASS** — полный регресс зелёный (941 passed), все 24 теста ORCH-073 PASS, smoke API OK,
маркеры AC-1 присутствуют в `origin/main`, прод-контейнер не затронут. Задача готова к
переходу на стадию `deploy-staging` (где будет проверен AC-10 — воспроизведение «исправлено
навсегда» на двух задачах с правкой CHANGELOG).

View File

@@ -0,0 +1,12 @@
---
deploy_status: SUCCESS
work_item: ORCH-073
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,36 @@
---
staging_status: SUCCESS
timestamp: 2026-06-08T13:29:31Z
base_url: http://localhost:8501
---
# Staging Gate Log
Staging test suite completed. All REAL pipeline checks passed (8/10).
Run canonically inside the `orchestrator-staging` container (ORCH-048, ADR-001):
```
docker exec orchestrator-staging \
python3 /repos/orchestrator/scripts/staging_check.py \
--base-url http://localhost:8501 --mode stub
```
Exit code: **0** → advance.
## Results
- **Block A (SMOKE)**: A1 /health, A2 /queue, A3 ORCH_STAGING=true — all PASS
- **Block B (ACCESS)**: B4 Plane sandbox, B5 Gitea sandbox (push=true), B6 registry isolation (sandbox present, prod ET/ORCH absent) — all PASS
- **Block C (E2E, stub)**: C7 create issue, C8 trigger pipeline — PASS; C9a/C9b — waived sandbox-infra
REAL failed: none.
## Infra waiver (ORCH-061)
```
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
```
The two waived checks (C9a branch, C9b analyst-job) depend on SANDBOX bot accounts being members of the sandbox Plane project — infra-only, not pipeline regression. Tolerated under `staging_infra_tolerance_enabled=true` since every REAL check is green. Exit code remains the source of truth (fail-closed: any REAL failure still yields exit 1).

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

@@ -1,3 +1,4 @@
from pydantic import field_validator
from pydantic_settings import BaseSettings
@@ -374,17 +375,115 @@ 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
# ORCH-026: intra-repo merge serialisation (Level A) + declarative task
# dependencies (Level B). Level A reuses the ORCH-043/065 merge-lease window
# (no new mechanism) — the merge-lease already serialises "merge -> main-updated"
# per repo; the ONLY new behaviour is an unconditional pre-merge rebase. Level B
# adds a new ADDITIVE job_deps table + a NOT EXISTS gate in claim_next_job. Both
# features are inert without data (no applicable repo / no declared deps) ->
# zero regression for enduro-trails.
# premerge_rebase_always -> Level A (A-2): when True, check_branch_mergeable
# ALWAYS rebases the task branch onto the CURRENT
# origin/main UNDER the merge-lease (not only when
# branch_is_behind_main) — a deterministic anti-phantom
# that does not depend on the ancestor check's precision.
# auto_rebase_onto_main is a cheap no-op on an already
# up-to-date branch (rc 0, push up-to-date, CI not
# retriggered). Scope = merge_gate_repos (empty ->
# self-hosting). Kill-switch (False -> exactly the
# ORCH-043 behaviour: rebase only when behind). Env
# ORCH_PREMERGE_REBASE_ALWAYS.
# task_deps_enabled -> Level B (B-2): global kill-switch for the scheduler
# dependency gate. False -> claim_next_job is 1:1 as
# ORCH-1 (the NOT EXISTS clause is omitted). Inert when
# job_deps is empty. Env ORCH_TASK_DEPS_ENABLED.
# task_deps_source -> declaration source: db|plane|hybrid (default db).
# The scheduler ALWAYS reads the DB cache (offline-safe
# hot path); plane/hybrid additionally ingest Plane
# `blocked-by` relations into job_deps at task creation.
# Env ORCH_TASK_DEPS_SOURCE.
premerge_rebase_always: bool = True
task_deps_enabled: bool = True
task_deps_source: str = "db"
# ORCH-073 (ADR-001 Р-4): main-integrity regression guard. After the merge-verify
# under-gate confirms the deployed SHA is an ancestor of origin/main (FR-1), a
# secondary deterministic (no-LLM) guard checks that a declarative set of markers
# for recently-merged tasks (MAIN_REGRESSION_MARKERS in merge_gate.py) is still
# present in origin/main — i.e. a CHANGELOG-rebase or phantom-merge did not silently
# roll back a neighbouring task's code. A missing marker (deterministic count==0) ->
# ALERT + HOLD (task stays on `deploy`, NOT done); an infra/git error on the grep
# itself -> fail-OPEN (do not block done; SHA-in-main remains the primary gate).
# regression_guard_enabled -> kill-switch (env ORCH_REGRESSION_GUARD_ENABLED);
# reuses the merge_verify_applies scope (self-hosting /
# merge_verify_repos), so non-self repos are a no-op.
regression_guard_enabled: bool = True
# 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
# ORCH-069: QG-0 upper title-length limit (entry gate _qg0_errors). The 80-char
# cap was a hygiene limit, not structural (slug is cut to [:30] independently,
# DB title TEXT is unbounded). Configurable via env ORCH_QG0_TITLE_MAX; default
# 200 (was hardcoded 80). Invalid/empty value -> default (graceful, no crash).
qg0_title_max: int = 200
@field_validator("qg0_title_max", mode="before")
@classmethod
def _qg0_title_max_default(cls, v):
# Graceful (ORCH-069 AC-3): empty / non-numeric env -> default 200, the
# process must not crash on startup. Never raises (self-hosting safety).
try:
if v is None or (isinstance(v, str) and v.strip() == ""):
return 200
return int(v)
except (TypeError, ValueError):
return 200
class Config:
env_prefix = "ORCH_"

130
src/db.py
View File

@@ -123,6 +123,24 @@ def init_db():
# tracker can show "твоё время" without recomputing from activity history.
_ensure_column(conn, "tasks", "brd_review_started_at", "TEXT")
_ensure_column(conn, "tasks", "brd_review_ended_at", "TEXT")
# ORCH-026 (Level B): declarative task dependencies. job_deps stores the
# directed edge "task_id (B) is blocked-by depends_on_task_id (A)". The
# scheduler gate in claim_next_job keeps B queued until every A reaches
# tasks.stage='done'. Purely ADDITIVE (CREATE TABLE/INDEX IF NOT EXISTS, no
# change to jobs/tasks/agent_runs/events columns) -> idempotent and safe on
# the live shared prod DB (enduro-trails data untouched). The logical FK on
# tasks.id is intentional (no REFERENCES, mirrors jobs.task_id) so the
# migration cannot fail on a pre-existing DB. See 08-data-requirements.md.
conn.executescript("""
CREATE TABLE IF NOT EXISTS job_deps (
task_id INTEGER NOT NULL,
depends_on_task_id INTEGER NOT NULL,
created_at TEXT DEFAULT (datetime('now')),
PRIMARY KEY (task_id, depends_on_task_id)
);
CREATE INDEX IF NOT EXISTS idx_job_deps_task ON job_deps(task_id);
CREATE INDEX IF NOT EXISTS idx_job_deps_depends ON job_deps(depends_on_task_id);
""")
conn.commit()
conn.close()
@@ -466,12 +484,28 @@ def claim_next_job() -> dict | None:
so the SELECT+UPDATE pair is consistent. Returns the claimed job dict or None
when the queue is empty.
"""
# ORCH-026 (Level B, B-2): scheduler dependency gate. When task_deps_enabled
# is on, a job whose task has an UNFINISHED declared dependency
# (job_deps.depends_on_task_id -> a task with stage != 'done') is NOT
# claimable -> it stays 'queued' without occupying a max_concurrency slot.
# Jobs with a NULL task_id (no task) or with no job_deps rows are unaffected
# (NOT EXISTS is True). Kill-switch off -> the clause is omitted -> 1:1 the
# ORCH-1 query. The gate reads only the DB (offline-safe hot path).
dep_gate = ""
if getattr(settings, "task_deps_enabled", False):
dep_gate = (
"AND NOT EXISTS ("
" SELECT 1 FROM job_deps d JOIN tasks t ON t.id = d.depends_on_task_id "
" WHERE d.task_id = jobs.task_id AND t.stage != 'done'"
") "
)
conn = get_db()
try:
while True:
row = conn.execute(
"SELECT id FROM jobs WHERE status='queued' "
"AND (available_at IS NULL OR available_at <= datetime('now')) "
f"{dep_gate}"
"ORDER BY id LIMIT 1"
).fetchone()
if not row:
@@ -705,6 +739,102 @@ def recent_jobs(limit: int = 10) -> list[dict]:
return [dict(r) for r in rows]
# ---------------------------------------------------------------------------
# ORCH-026 (Level B): declarative task-dependency helpers
# ---------------------------------------------------------------------------
def add_dependency(task_id: int, depends_on_task_id: int) -> bool:
"""Declare that task ``task_id`` (B) is blocked-by ``depends_on_task_id`` (A).
Idempotent INSERT OR IGNORE against the job_deps PK (re-declaring the same
edge is a no-op). A self-edge (task depends on itself) is rejected — it would
deadlock the task forever and can never be satisfied. never-raise
(self-hosting safety, AC-G1): any DB error -> returns False, the caller must
not crash the webhook / worker. Returns True iff a NEW edge row was inserted.
"""
if task_id is None or depends_on_task_id is None:
return False
if task_id == depends_on_task_id:
return False
try:
conn = get_db()
try:
cur = conn.execute(
"INSERT OR IGNORE INTO job_deps (task_id, depends_on_task_id) "
"VALUES (?, ?)",
(task_id, depends_on_task_id),
)
conn.commit()
return cur.rowcount == 1
finally:
conn.close()
except Exception:
return False
def get_dependencies(task_id: int) -> list[int]:
"""Return the list of depends_on_task_id (A) that ``task_id`` (B) waits for.
never-raise: any DB error -> [] (conservative: caller treats the task as
having no declared dependency rather than crashing).
"""
try:
conn = get_db()
try:
rows = conn.execute(
"SELECT depends_on_task_id FROM job_deps WHERE task_id = ?",
(task_id,),
).fetchall()
finally:
conn.close()
return [r[0] for r in rows]
except Exception:
return []
def get_dependency_edges() -> list[tuple[int, int]]:
"""Return ALL declared edges as ``(task_id, depends_on_task_id)`` tuples.
Used by the cycle detector (DFS over the whole declared graph) and the
/queue snapshot. never-raise -> [] on any DB error.
"""
try:
conn = get_db()
try:
rows = conn.execute(
"SELECT task_id, depends_on_task_id FROM job_deps"
).fetchall()
finally:
conn.close()
return [(r[0], r[1]) for r in rows]
except Exception:
return []
def get_unfinished_dependencies(task_id: int) -> list[dict]:
"""Return the UNFINISHED dependencies of ``task_id`` (A's not yet 'done').
Each dict carries the predecessor's ``id``, ``work_item_id`` and ``stage``
so the readiness gate / Telegram waiting-line can name what B is waiting for.
never-raise -> [] on any DB error (treated as "ready", consistent with the
scheduler omitting the gate on failure).
"""
try:
conn = get_db()
try:
rows = conn.execute(
"SELECT t.id AS id, t.work_item_id AS work_item_id, t.stage AS stage "
"FROM job_deps d JOIN tasks t ON t.id = d.depends_on_task_id "
"WHERE d.task_id = ? AND t.stage != 'done'",
(task_id,),
).fetchall()
finally:
conn.close()
return [dict(r) for r in rows]
except Exception:
return []
# ---------------------------------------------------------------------------
# ORCH-1b (resilience): transient backoff helpers
# ---------------------------------------------------------------------------

View File

@@ -147,6 +147,8 @@ async def queue():
from .reconciler import reconciler
from .job_reaper import reaper
from . import post_deploy
from . import merge_gate
from . import task_deps
return {
"counts": job_status_counts(),
"max_concurrency": worker.max_concurrency,
@@ -155,5 +157,9 @@ async def queue():
"reconcile": reconciler.status(),
"reaper": reaper.status(),
"post_deploy": post_deploy.status(),
"merge_verify": merge_gate.merge_verify_status(),
# ORCH-026 (G-2): declarative task-dependency observability (read-only,
# NOT a source of truth) — declared edges, blocked tasks, detected cycle.
"task_deps": task_deps.snapshot(),
"recent": recent_jobs(10),
}

View File

@@ -445,25 +445,30 @@ def reclaim_stale_lease(repo: str) -> bool:
# ORCH-065: idempotent merge finalization guard (Problem C)
# ---------------------------------------------------------------------------
def pr_already_merged(repo: str, branch: str) -> bool:
"""Return True iff the PR for ``branch`` is ALREADY merged (ADR-001 Р-3, FR-3.2).
"""Return True iff the **code-PR of ``branch``** is ALREADY merged (idempotency-guard).
A deterministic, read-only guard the merge path consults BEFORE attempting a
(second) merge so a re-driven / reaped task is idempotent: an already-merged
PR -> no-op, never a duplicate merge and never an error. This is the ONLY new
merge-related helper and it does NOT merge — it only READS the PR state via
the existing Gitea client, so it does not introduce duplicate merge logic.
ORCH-073 ADR-001 Р-2 (FR-2): this is an **idempotency-guard for ``merge_pr``**, NOT
a source of truth for ``done`` (the only proof of merge is SHA-in-main, FR-1 /
``verify_merged_to_main``). It lets a re-driven / reaped ``merge_pr`` be idempotent:
the code-PR is already merged -> no-op, never a duplicate merge.
Consultation point: the actual merge actor is the **deployer agent** (it merges
the feature PR at the start of the ``deploy`` stage — see webhooks/gitea.py),
so the wiring lives in the deployer prompt (``.openclaw/agents/deployer.md``),
which runs this exact function before any (re-)merge. The merge-gate quality
check (``qg.checks.check_branch_mergeable``) is intentionally NOT modified
(ORCH-065 AC-13: ``check_*`` behaviour unchanged) — it runs on the FIRST
deploy-staging -> deploy edge and does not re-run on a ``deploy``-stage re-drive,
which is exactly where the second-merge risk lives.
Root-cause fix (G4 audit): the previous implementation returned True for ANY
``merged == True`` PR returned by ``GET /pulls?state=all&head=<branch>``. Gitea's
``head`` query-param filters unreliably for a bare branch name, so auto docs-PRs
(staging/deploy logs, ``head=docs/*``) leaked into the result and were counted as
"merged" — the ORCH-067/069 phantom-merge. We now apply an EXPLICIT in-loop filter
instead of trusting the query-param: a PR counts only when it carries the code of
THIS feature-branch into ``main``:
* ``pr.merged is True`` AND
* ``pr.head.ref == branch`` (the code of exactly this feature-branch) AND
* ``pr.base.ref == "main"`` (target is main, not a docs/other base).
This excludes auto docs-PRs (different ``head.ref``) and PRs onto a non-``main``
base, so a merged docs-PR can no longer make ``merge_pr`` skip a real code merge.
Queries Gitea ``GET /repos/{owner}/{repo}/pulls?state=all&head=<branch>`` and
reports True when any matching PR has ``merged == True``. Never raises (AC-9):
reports True only when a matching PR passes the filter above. Never raises (AC-9):
any HTTP/parse error -> ``False`` (conservative: "not known-merged" lets the
normal gate re-evaluate rather than silently skipping a real merge).
"""
@@ -479,9 +484,333 @@ def pr_already_merged(repo: str, branch: str) -> bool:
if resp.status_code != 200:
return False
for pr in resp.json() or []:
if pr.get("merged") is True:
if (
pr.get("merged") is True
and pr.get("head", {}).get("ref") == branch
and pr.get("base", {}).get("ref") == "main"
):
return True
return False
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,
"main_regressed_alerts_total": 0, # ORCH-073 Р-4: regression-guard HOLD+alert count.
"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 note_main_regressed_alert(work_item_id: str | None) -> None:
"""Bump the 'main regressed (marker missing)' counter (ORCH-073 Р-4). Never raises."""
try:
_MERGE_VERIFY_COUNTERS["main_regressed_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"],
"main_regressed_alerts_total": _MERGE_VERIFY_COUNTERS["main_regressed_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`` AND base ref == ``main`` -> its index. ORCH-073 ADR-001 Р-3
(FR-3) adds the ``base == main`` filter so the actor merges exactly the
feature code-PR and never an auto docs-PR / a PR onto a foreign base. No
such 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
and pr.get("base", {}).get("ref") == "main"
):
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 — ORCH-073 ADR-001 Р-1 (FR-1): the merge is confirmed by
the SINGLE, authoritative fact "the deployed commit IS an ancestor of the current
``origin/main``":
* after ``git fetch origin main`` (in the per-branch worktree),
``git merge-base --is-ancestor <sha> origin/main`` returns ``rc == 0``.
The former OR-branch ``pr_already_merged(repo, branch)`` was REMOVED: a merged
``PR.merged == true`` is no longer sufficient to confirm a merge. That branch was
the ORCH-067/069 phantom-merge root cause — an auto docs-PR (staging/deploy logs)
counted as "merged" via the unreliable Gitea ``head`` query, turning merge-verify
falsely GREEN while the code-PR was never merged. ``pr_already_merged`` now serves
ONLY as an idempotency-guard inside ``merge_pr`` (Р-2/Р-3), never as proof of merge.
``sha`` is the validated commit (``image_freshness.validated_revision`` =
worktree ``git rev-parse HEAD``). An empty ``sha`` is inconclusive -> ``False``
(fail-closed: alert + HOLD), since the SHA-in-main check cannot run without it.
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 not sha:
logger.warning(
"verify_merged_to_main: empty SHA for %s/%s -> cannot confirm SHA-in-main",
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
# ---------------------------------------------------------------------------
# ORCH-073 (ADR-001 Р-4): main-integrity regression guard.
#
# A secondary, deterministic (no-LLM) guard that runs in `_handle_merge_verify`
# AFTER the SHA-in-main check (verify_merged_to_main, FR-1) confirms the deployed
# commit, and BEFORE the task is stamped `done`. It checks that a DECLARATIVE set
# of markers for recently-merged tasks is still present in `origin/main` — i.e. a
# CHANGELOG-rebase / phantom-merge did not silently roll back a neighbouring task's
# code (the ORCH-067/069 failure mode, which SHA-in-main alone would not catch when
# the deployed SHA itself IS in main but a sibling's code is gone).
# ---------------------------------------------------------------------------
# Declarative, append-only marker set (ADR-001 Р-4). Each future task that lands
# significant code SHOULD append its own (task, marker_substring, path) row so the
# guard protects it from a later phantom-merge / rebase rollback. Kept in code (not
# DB / Plane — a non-goal) so it versions together with the fix it protects.
MAIN_REGRESSION_MARKERS: list[tuple[str, str, str]] = [
("ORCH-067", "plane_issue_link", "src/notifications.py"),
("ORCH-069", "qg0_title_max", "src/config.py"),
("ORCH-071", "verify_merged_to_main", "src/merge_gate.py"),
("ORCH-073", "check_main_regression", "src/merge_gate.py"),
]
def check_main_regression(repo: str, branch: str) -> tuple[bool, str]:
"""Verify the declarative marker set is still present in ``origin/main``.
ORCH-073 ADR-001 Р-4 (FR-5). For each ``(task, marker, path)`` in
``MAIN_REGRESSION_MARKERS`` run ``git grep -c <marker> origin/main -- <path>`` in
the per-branch worktree (after ``git fetch origin main``). A DETERMINISTIC count
of ``0`` for any marker means a neighbouring task's code was rolled back ->
regression.
Returns ``(ok, reason)``:
* ``(True, "markers intact (<n>)")`` — every marker present -> proceed.
* ``(False, "main regressed: <task> ...")`` — a marker is deterministically
absent (count==0) -> caller HOLDs the task (NOT done) + alerts.
**Fail-OPEN on infra error** (intentional trade-off, ADR-001 Р-4): any git/OS
error on the grep itself -> ``(True, "guard inconclusive: <reason>")`` so a flaky
git never produces a false HOLD. "Regressed" is asserted ONLY on a deterministic
``count == 0``, never on "could not determine". The PRIMARY fail-closed gate is
SHA-in-main (FR-1); this marker-grep is a secondary, best-effort guard.
Never raises (INV-1): any unexpected error -> ``(True, "guard error: ...")``.
"""
try:
try:
wt = ensure_worktree(repo, branch)
except Exception as e: # noqa: BLE001 - never-raise contract -> fail-open
logger.warning(
"check_main_regression: worktree error for %s/%s: %s (fail-open)",
repo, branch, e,
)
return True, f"guard inconclusive: worktree error: {e}"
try:
subprocess.run(
["git", "-C", wt, "fetch", "origin", "main"],
capture_output=True, timeout=settings.merge_verify_timeout_s,
)
except (subprocess.SubprocessError, OSError) as e:
logger.warning(
"check_main_regression: fetch error for %s/%s: %s (fail-open)",
repo, branch, e,
)
return True, f"guard inconclusive: fetch error: {e}"
for task, marker, path in MAIN_REGRESSION_MARKERS:
try:
r = subprocess.run(
["git", "-C", wt, "grep", "-c", marker, "origin/main", "--", path],
capture_output=True, text=True, timeout=_SHORT_TIMEOUT,
)
except (subprocess.SubprocessError, OSError) as e:
# Infra error on this marker -> fail-open (do NOT assert regression).
logger.warning(
"check_main_regression: grep error for %s (%s @ %s): %s (fail-open)",
task, marker, path, e,
)
return True, f"guard inconclusive: grep error for {task}: {e}"
# git grep exit codes: 0 = match(es) found, 1 = no match, >1 = real error.
if r.returncode == 0:
continue
if r.returncode == 1:
# Deterministic absence -> regression of a neighbouring task's code.
logger.warning(
"check_main_regression: marker MISSING in origin/main for %s "
"(%s @ %s) -> main regressed", task, marker, path,
)
return False, f"main regressed: {task} code missing ({marker} @ {path})"
# rc > 1 -> git error (e.g. bad path/ref) -> inconclusive -> fail-open.
logger.warning(
"check_main_regression: ambiguous git grep rc=%s for %s (%s @ %s) "
"(fail-open)", r.returncode, task, marker, path,
)
return True, f"guard inconclusive: git grep rc={r.returncode} for {task}"
return True, f"markers intact ({len(MAIN_REGRESSION_MARKERS)})"
except Exception as e: # noqa: BLE001 - never-raise contract -> fail-open
logger.warning(
"check_main_regression unexpected error for %s/%s: %s (fail-open)",
repo, branch, e,
)
return True, f"guard error: {e}"

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,43 @@ 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]
# ORCH-026 (B-4): waiting-line for a task blocked by an unfinished declared
# dependency. Shows WHAT the task is waiting on ("⏳ ждёт ORCH-NNN"),
# so the single tracker card (invariant preserved) makes the wait visible.
# Never breaks the render: any error -> no waiting-line.
if not done:
try:
from . import task_deps
from .config import settings as _settings
if getattr(_settings, "task_deps_enabled", False):
ready, waiting_on = task_deps.is_task_ready(task_id)
if not ready and waiting_on:
waits = ", ".join(link_for(w) for w in waiting_on)
lines.append(f"⏳ ждёт {waits}")
except Exception:
pass
def _stage_line(label, run):
usage = {
@@ -704,38 +734,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 +1017,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 +1051,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

@@ -402,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
@@ -413,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):
@@ -429,6 +433,72 @@ def fetch_issue_state(issue_id: str, project_id: str) -> str | None:
return None
def fetch_blocked_by_issue_ids(issue_id: str, project_id: str, timeout: int = 10) -> list[str]:
"""ORCH-026 (B-1): list the Plane issue UUIDs that ``issue_id`` is BLOCKED-BY.
Reads the Plane issue-relation endpoint and returns the related issue UUIDs
declared as ``blocked_by`` (i.e. the predecessors A that this task B waits
for). Plane's relation payload shape has varied across versions, so the parse
is defensive: it accepts either a grouped object (``{"blocked_by": [...]}``)
or a flat list of ``{"relation_type": ..., "related_issue": ...}`` rows, and
pulls a uuid from ``related_issue`` / ``issue`` / ``id`` (bare uuid or nested
``{"id": ...}``).
never-raise (AC-G1, self-hosting): a Plane outage / non-2xx / unexpected
shape -> ``[]`` (no edge declared), so the ingestion degrades conservatively
and the pipeline never stalls on the network.
"""
if not issue_id or not project_id:
return []
url = (
f"{PLANE_BASE}/workspaces/{WORKSPACE}/projects/{project_id}"
f"/issues/{issue_id}/issue-relation/"
)
try:
resp = httpx.get(url, headers=PLANE_HEADERS, timeout=timeout)
resp.raise_for_status()
body = resp.json()
except Exception as e:
logger.warning(f"fetch_blocked_by_issue_ids failed for {issue_id}: {e}")
return []
def _uuid_of(row) -> str | None:
if isinstance(row, str):
return row
if isinstance(row, dict):
for key in ("related_issue", "issue", "id"):
v = row.get(key)
if isinstance(v, dict):
v = v.get("id")
if v:
return str(v)
return None
out: list[str] = []
try:
rows = []
if isinstance(body, dict):
# Grouped shape: {"blocked_by": [...], "blocking": [...], ...}
if "blocked_by" in body and isinstance(body["blocked_by"], list):
rows = body["blocked_by"]
else:
# Flat shape nested under common envelope keys.
rows = body.get("results") or body.get("relations") or []
elif isinstance(body, list):
rows = body
for row in rows:
# In the flat shape, keep only blocked_by rows.
if isinstance(row, dict) and row.get("relation_type") not in (None, "blocked_by"):
continue
uid = _uuid_of(row)
if uid and uid != issue_id:
out.append(uid)
except Exception as e:
logger.warning(f"fetch_blocked_by_issue_ids parse error for {issue_id}: {e}")
return []
return out
import re as _re

View File

@@ -673,8 +673,19 @@ def check_branch_mergeable(repo: str, work_item_id: str, branch: str) -> tuple[b
return False, reason
try:
# ORCH-026 (Level A, A-2): proactive pre-merge rebase. When
# premerge_rebase_always is on, ALWAYS rebase onto the CURRENT
# origin/main under the held lease — even when branch_is_behind_main
# says "not behind". The ancestor check can miss a divergence
# (squash/force-push history, ORCH-073 phantom-merge class), so an
# unconditional rebase is a deterministic anti-phantom: it guarantees
# B carries A's code before merge. auto_rebase_onto_main is a cheap
# no-op on an already up-to-date branch (rc 0, push up-to-date, CI not
# retriggered). Kill-switch off -> 1:1 the ORCH-043 short-circuit
# below (rebase only when behind).
always = bool(getattr(settings, "premerge_rebase_always", False))
# Double-check under the lease: another task may have just merged.
if not merge_gate.branch_is_behind_main(repo, branch):
if not always and not merge_gate.branch_is_behind_main(repo, branch):
logger.info("check_branch_mergeable: %s up-to-date with main", branch)
return True, "branch up-to-date with main"

View File

@@ -67,8 +67,9 @@ from .plane_sync import (
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
from . import task_deps
logger = logging.getLogger("orchestrator.reconciler")
@@ -165,6 +166,16 @@ class Reconciler:
f"reconciler F-1: task {task.get('id')} "
f"(stage={task.get('stage')}) failed: {e}"
)
# ORCH-026 (B-3) backstop: surface ANY dependency deadlock in the declared
# graph, even one whose tasks are not individually evaluated above (e.g. no
# active queued job). One alert per cycle; never-raise.
if settings.task_deps_enabled:
try:
cyc = task_deps.find_any_cycle()
if cyc:
task_deps.handle_cycle(cyc)
except Exception as e: # noqa: BLE001 - never break the sweep
logger.error(f"reconciler F-1: cycle backstop failed: {e}")
def _reconcile_gate_task(self, task: dict) -> None:
task_id = task["id"]
@@ -194,6 +205,18 @@ class Reconciler:
# Networked; runs after Guard 1 so escalated tasks never hit Plane.
if self._is_blocked_or_needs_input(task):
return
# ORCH-026 Guard 3 (B-5): a task blocked by an unfinished declared
# dependency is legitimately waiting, NOT stuck -> F-1 must not advance it
# past its depends-on (mirrors the Blocked/Needs-Input skip). Local DB,
# never-raise (is_task_ready fails OPEN). If the wait is actually a
# dependency DEADLOCK (cycle), surface it (Blocked + alert) once.
if settings.task_deps_enabled:
ready, _waiting = task_deps.is_task_ready(task_id)
if not ready:
cyc = task_deps.detect_cycle(task_id)
if cyc:
task_deps.handle_cycle(cyc)
return
result = advance_if_gate_passed(
task_id,
stage,
@@ -447,7 +470,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

View File

@@ -670,9 +670,9 @@ def check_security_gate(repo: str, work_item_id: str, branch: str) -> tuple[bool
dep_result.detail,
)
try:
from .notifications import send_telegram
from .notifications import send_telegram, link_for
send_telegram(
f"⚠️ {work_item_id}: dep-audit недоступен фид CVE "
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 (секреты проверены оффлайн).")

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

@@ -44,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,
@@ -346,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
@@ -595,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
@@ -654,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
@@ -701,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
@@ -758,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
@@ -802,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
@@ -898,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."
)
@@ -953,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."
)
@@ -1039,7 +1056,7 @@ def _handle_security_gate(
else:
set_issue_blocked(work_item_id)
send_telegram(
f"\U0001f6a8 {work_item_id}: Security-гейт still failing after "
f"\U0001f6a8 {link_for(work_item_id)}: Security-гейт still failing after "
f"{MAX_DEVELOPER_RETRIES} developer retries ({reason}). "
f"Manual intervention needed."
)
@@ -1116,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."
)
@@ -1174,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(
@@ -1209,7 +1226,7 @@ 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
@@ -1238,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})"
@@ -1260,6 +1277,164 @@ def _deploy_finalize_defer_count(task_id: int) -> int:
return n
def _hold_main_regressed(
task_id, repo, work_item_id, branch, guard_msg: str, result: AdvanceResult
) -> bool:
"""HOLD the task because the regression guard found neighbouring code missing.
ORCH-073 Р-4 (FR-5 / AC-5): the deployed SHA IS in `main` (FR-1 passed) but a
declarative marker of a recently-merged task is gone -> a phantom-merge / rebase
rolled back sibling code. Reaction is ALERT-only + HOLD (Telegram + Plane
``set_issue_blocked`` + comment), task stays on `deploy` (NOT done), NO rollback
to development (an infra defect, not a code fault — symmetric to the not-merged
HOLD). Returns ``True`` (INTERVENED). Never breaks the HOLD on a notify error.
"""
merge_gate.note_main_regressed_alert(work_item_id)
msg = (
f"main regressed: {guard_msg} (repo={repo}, branch={branch}, "
f"wi={work_item_id}). Соседний код пропал из `main` — задача удержана на "
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 Регресс `main`: " + guard_msg + ". Код соседней задачи "
"пропал из `main`. Задача удержана на `deploy` (НЕ done) — нужно "
"восстановить код и повторить approve.",
author="deployer",
)
except Exception as e: # noqa: BLE001 - never break the HOLD
logger.warning(f"Task {task_id}: plane regressed 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}: main-regressed telegram failed: {e}")
result.alerted = True
result.note = "main-regressed-hold"
result.advanced = False
return True
def _handle_merge_verify(task_id, repo, work_item_id, branch, result: AdvanceResult) -> bool:
"""ORCH-071 merge-verify under-gate on the `deploy -> done` edge.
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:
# ORCH-073 Р-4 (FR-5): secondary main-integrity regression guard. The
# deployed SHA is in main (FR-1), but a CHANGELOG-rebase / phantom-merge
# could still have rolled back a NEIGHBOURING task's code. Verify the
# declarative marker set is intact; a deterministic miss -> HOLD + alert
# (NOT done, no rollback — an infra defect, not a code fault). Fail-OPEN
# on a git error of the guard itself (SHA-in-main remains the primary
# gate). Honours the same scope/kill-switch as the under-gate.
if settings.regression_guard_enabled:
guard_ok, guard_msg = merge_gate.check_main_regression(repo, branch)
if not guard_ok:
return _hold_main_regressed(
task_id, repo, work_item_id, branch, guard_msg, result
)
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).
@@ -1307,7 +1482,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(
@@ -1328,7 +1503,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(

335
src/task_deps.py Normal file
View File

@@ -0,0 +1,335 @@
"""ORCH-026 (Level B): declarative task-dependency logic.
Leaf module — pure, unit-testable functions over the additive ``job_deps`` table
(see src/db.py / 08-data-requirements.md). It answers two questions the rest of
the pipeline asks:
* "is task B ready to run?" — every declared predecessor A reached
``tasks.stage = 'done'`` (``is_task_ready``). The scheduler gate in
``db.claim_next_job`` enforces the same predicate in SQL; this Python copy is
for the reconciler skip and for naming WHAT a task waits on (visibility).
* "is there a dependency deadlock?" — a directed cycle A->B->A (or longer) can
never be satisfied, so the tasks in it would wait forever. ``detect_cycle`` /
``find_any_cycle`` find one deterministically; ``handle_cycle`` escalates it
to Blocked + alert so the deadlock is visible instead of silent.
never-raise contract (AC-G1, self-hosting safety): EVERY public function
degrades conservatively on any error (DB/import) and NEVER propagates an
exception into the worker / reconciler / webhook. Readiness fails OPEN
(``True``) so a transient DB error cannot wedge the whole queue; cycle detection
fails CLOSED-safe (``None`` = "no cycle proven", do not block).
"""
from __future__ import annotations
import logging
from . import db
from .config import settings
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Readiness gate (B-2)
# ---------------------------------------------------------------------------
def is_task_ready(task_id: int) -> tuple[bool, list[str]]:
"""Return ``(ready, waiting_on)`` for a task.
``ready`` is True when the task has no declared dependency whose predecessor
is still un-done (``tasks.stage != 'done'``). ``waiting_on`` is the list of
predecessor work-item ids (e.g. ``["ORCH-010"]``) the task is still blocked
by — used for the Telegram waiting-line / Plane visibility.
never-raise: any error -> ``(True, [])`` (fail OPEN — consistent with the
scheduler omitting the gate when the DB read fails; a transient error must
not wedge an otherwise-claimable task).
"""
if task_id is None:
return True, []
try:
unfinished = db.get_unfinished_dependencies(task_id)
except Exception:
return True, []
if not unfinished:
return True, []
waiting_on = [
str(d.get("work_item_id") or d.get("id"))
for d in unfinished
]
return False, waiting_on
# ---------------------------------------------------------------------------
# Cycle / deadlock detection (B-3)
# ---------------------------------------------------------------------------
def _build_adjacency(edges: list[tuple[int, int]]) -> dict[int, list[int]]:
"""Build a ``task_id -> [depends_on_task_id, ...]`` adjacency map.
Edge direction follows the dependency: an edge (B, A) means "B depends on A",
so we traverse from a dependent task towards its predecessors. A cycle in
this graph is an unsatisfiable deadlock.
"""
adj: dict[int, list[int]] = {}
for task_id, depends_on in edges:
adj.setdefault(task_id, []).append(depends_on)
return adj
def _find_cycle_from(start: int, adj: dict[int, list[int]]) -> list[int] | None:
"""Iterative DFS from ``start``; return a cycle path if one is reachable.
Returns the node sequence closing the cycle (e.g. ``[A, B, A]``) or None.
Iterative (explicit stack) so a pathological deep graph cannot blow the
Python recursion limit — relevant on the shared prod process.
"""
WHITE, GREY, BLACK = 0, 1, 2
color: dict[int, int] = {}
parent: dict[int, int] = {}
# stack of (node, is_exit): is_exit=True marks the post-visit (color BLACK).
stack: list[tuple[int, bool]] = [(start, False)]
while stack:
node, is_exit = stack.pop()
if is_exit:
color[node] = BLACK
continue
if color.get(node, WHITE) != WHITE:
continue
color[node] = GREY
stack.append((node, True))
for nxt in adj.get(node, []):
c = color.get(nxt, WHITE)
if c == GREY:
# Back-edge -> cycle. Reconstruct path nxt..node via parent.
path = [node]
cur = node
while cur != nxt and cur in parent:
cur = parent[cur]
path.append(cur)
path.reverse()
path.append(nxt)
return path
if c == WHITE:
parent[nxt] = node
stack.append((nxt, False))
return None
def detect_cycle(task_id: int, edges: list[tuple[int, int]] | None = None) -> list[int] | None:
"""Detect a dependency cycle reachable from ``task_id``.
Returns the cycle path (node sequence, first == last) or None when the graph
reachable from ``task_id`` is acyclic. ``edges`` may be injected (unit tests);
otherwise the full declared edge set is read from the DB.
never-raise: any error -> None (do not falsely claim a deadlock on an error).
"""
if task_id is None:
return None
try:
if edges is None:
edges = db.get_dependency_edges()
adj = _build_adjacency(edges)
return _find_cycle_from(task_id, adj)
except Exception:
return None
def find_any_cycle(edges: list[tuple[int, int]] | None = None) -> list[int] | None:
"""Backstop: detect ANY cycle in the whole declared graph.
Used by the reconciler tick to surface a deadlock even when no specific task
is being evaluated. Returns the first cycle found or None. never-raise -> None.
"""
try:
if edges is None:
edges = db.get_dependency_edges()
adj = _build_adjacency(edges)
for node in list(adj.keys()):
cyc = _find_cycle_from(node, adj)
if cyc:
return cyc
return None
except Exception:
return None
def _work_item_id_for(task_id: int) -> str | None:
"""Best-effort ``tasks.work_item_id`` lookup for a task_id (never-raise)."""
try:
conn = db.get_db()
try:
row = conn.execute(
"SELECT work_item_id FROM tasks WHERE id = ?", (task_id,)
).fetchone()
finally:
conn.close()
return row[0] if row and row[0] else None
except Exception:
return None
def handle_cycle(cycle: list[int]) -> bool:
"""Escalate a detected dependency cycle: Blocked + alert (B-3, AC-G1).
For every task in the cycle, sets its Plane issue to Blocked (best-effort)
and sends ONE Telegram alert naming the cycle, so a deadlock is visible
instead of a silent forever-wait. Does NOT mutate job_deps / stages — the
declaration is the human's to fix. never-raise: any notify/Plane error is
swallowed; the worker/reconciler never crashes. Returns True if an alert was
attempted, False on a no-op / error.
"""
if not cycle:
return False
try:
# Map task ids -> work-item ids for a human-readable chain.
labels: list[str] = []
seen: set[int] = set()
for tid in cycle:
wi = _work_item_id_for(tid)
labels.append(wi or f"task#{tid}")
if tid not in seen:
seen.add(tid)
chain = " -> ".join(labels)
try:
from . import notifications, plane_sync
except Exception:
return False
# Blocked indication on each distinct issue in the cycle.
for tid in seen:
wi = _work_item_id_for(tid)
if wi:
try:
plane_sync.set_issue_blocked(wi)
except Exception:
pass
try:
notifications.send_telegram(
f"\U0001f6a8 ORCH-026: dependency DEADLOCK detected (cycle): {chain}. "
f"Tasks set to Blocked — fix the blocked-by declaration."
)
except Exception:
pass
logger.error("ORCH-026: dependency cycle detected: %s", chain)
return True
except Exception:
return False
# ---------------------------------------------------------------------------
# Declaration (B-1) — db.add_dependency + immediate cycle escalation
# ---------------------------------------------------------------------------
def declare_dependency(task_id: int, depends_on_task_id: int) -> bool:
"""Declare "task_id (B) blocked-by depends_on_task_id (A)" and check for a cycle.
Thin wrapper over ``db.add_dependency`` that, after a successful insert, runs
``detect_cycle`` from the new dependent — so a freshly-introduced deadlock is
surfaced (Blocked + alert) at declaration time (best UX, ADR B-3) rather than
only by the reconciler backstop. The edge is NOT rolled back on a cycle (the
SQL gate already keeps the cyclic tasks un-claimable; the human fixes the
declaration) — we make it VISIBLE. never-raise: any error -> False.
Returns True iff a NEW edge row was inserted (idempotent re-declaration ->
False, matching db.add_dependency).
"""
try:
inserted = db.add_dependency(task_id, depends_on_task_id)
# Always check for a cycle (even on a duplicate edge an existing cycle may
# now be relevant), but only escalate when one is actually found.
cyc = detect_cycle(task_id)
if cyc:
handle_cycle(cyc)
return inserted
except Exception:
return False
def ingest_plane_relations(
task_id: int, issue_id: str, project_id: str
) -> int:
"""B-1 (plane/hybrid source): import Plane ``blocked-by`` relations into job_deps.
Reads the issue's ``blocked_by`` predecessors from Plane, resolves each to a
local ``tasks.id`` (intra-repo only, v1) and declares the edge. A predecessor
not yet known locally (no task row) is SKIPPED — the scheduler can only gate
on tasks it knows; a re-run after that task is created will pick it up.
Active ONLY when ``task_deps_source`` is ``plane`` or ``hybrid`` (default
``db`` -> no Plane call on the hot creation path). never-raise (self-hosting):
any error -> 0 edges, the pipeline start is never blocked by Plane. Returns
the number of edges declared.
"""
source = (getattr(settings, "task_deps_source", "db") or "db").strip().lower()
if source not in ("plane", "hybrid"):
return 0
if not issue_id or not project_id:
return 0
try:
from . import plane_sync
blocked_by = plane_sync.fetch_blocked_by_issue_ids(issue_id, project_id)
except Exception:
return 0
declared = 0
for pred_issue in blocked_by:
try:
pred = db.get_task_by_plane_id(str(pred_issue))
if not pred:
continue
if declare_dependency(task_id, pred["id"]):
declared += 1
except Exception:
continue
return declared
# ---------------------------------------------------------------------------
# Observability (/queue snapshot, G-2)
# ---------------------------------------------------------------------------
def snapshot() -> dict:
"""Read-only summary of the dependency subsystem for GET /queue (G-2).
Returns a dict (NOT a source of truth — pure observability):
* ``enabled`` — task_deps_enabled flag;
* ``source`` — task_deps_source (db|plane|hybrid);
* ``edges`` — number of declared edges;
* ``blocked_tasks`` — list of ``{task_id, work_item_id, waiting_on}`` for
tasks with at least one un-done predecessor;
* ``cycle`` — a detected cycle path (work-item labels) or None.
never-raise: any error -> a minimal dict with the flags and empty data.
"""
enabled = bool(getattr(settings, "task_deps_enabled", False))
source = getattr(settings, "task_deps_source", "db")
try:
edges = db.get_dependency_edges()
blocked: list[dict] = []
for task_id in {e[0] for e in edges}:
ready, waiting_on = is_task_ready(task_id)
if not ready:
blocked.append({
"task_id": task_id,
"work_item_id": _work_item_id_for(task_id),
"waiting_on": waiting_on,
})
cyc = find_any_cycle(edges)
cycle_labels = None
if cyc:
cycle_labels = [(_work_item_id_for(t) or f"task#{t}") for t in cyc]
return {
"enabled": enabled,
"source": source,
"edges": len(edges),
"blocked_tasks": blocked,
"cycle": cycle_labels,
}
except Exception:
return {
"enabled": enabled,
"source": source,
"edges": 0,
"blocked_tasks": [],
"cycle": None,
}

View File

@@ -416,8 +416,11 @@ def _qg0_errors(name: str, description: str) -> list:
errors = []
if not name or len(name) < 5:
errors.append("Title \u0441\u043b\u0438\u0448\u043a\u043e\u043c \u043a\u043e\u0440\u043e\u0442\u043a\u0438\u0439 (\u043d\u0443\u0436\u043d\u043e >= 5 \u0441\u0438\u043c\u0432\u043e\u043b\u043e\u0432)")
if len(name) > 80:
errors.append("Title \u0441\u043b\u0438\u0448\u043a\u043e\u043c \u0434\u043b\u0438\u043d\u043d\u044b\u0439 (\u043c\u0430\u043a\u0441\u0438\u043c\u0443\u043c 80 \u0441\u0438\u043c\u0432\u043e\u043b\u043e\u0432)")
if len(name) > settings.qg0_title_max:
errors.append(
f"Title \u0441\u043b\u0438\u0448\u043a\u043e\u043c \u0434\u043b\u0438\u043d\u043d\u044b\u0439 "
f"(\u043c\u0430\u043a\u0441\u0438\u043c\u0443\u043c {settings.qg0_title_max} \u0441\u0438\u043c\u0432\u043e\u043b\u043e\u0432)"
)
if not description or len(description.strip()) < 20:
errors.append("Description \u0441\u043b\u0438\u0448\u043a\u043e\u043c \u043a\u043e\u0440\u043e\u0442\u043a\u0438\u0439 (\u043d\u0443\u0436\u043d\u043e >= 20 \u0441\u0438\u043c\u0432\u043e\u043b\u043e\u0432)")
@@ -605,6 +608,17 @@ async def start_pipeline(data: dict, project_id: str = ""):
except Exception as e:
logger.error(f"Failed to launch analyst for {work_item_id}: {e}")
# ORCH-026 (B-1): import declared Plane `blocked-by` relations into job_deps
# (only for task_deps_source = plane|hybrid; default `db` -> no-op, no Plane
# call). Best-effort, never-raise: a Plane outage must not block the start.
try:
from .. import task_deps
n = task_deps.ingest_plane_relations(task_id, plane_id, plane_project_id)
if n:
logger.info(f"Task {task_id}: ingested {n} blocked-by dependency edge(s)")
except Exception as e:
logger.warning(f"Task {task_id}: dependency ingestion skipped: {e}")
async def handle_comment(data: dict, project_id: str = ""):
"""Status-only verdict model: comments NEVER drive the pipeline.

View File

@@ -75,3 +75,27 @@ 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)
# ORCH-073: the regression guard (check_main_regression) runs real git in
# _handle_merge_verify's confirmed branch. Default it OFF too so unrelated
# deploy->done tests stay 1:1; the dedicated ORCH-073 tests re-enable it.
monkeypatch.setattr(_cfg.settings, "regression_guard_enabled", False, raising=False)
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"

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

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}, "base": {"ref": "main"}, "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"}, "base": {"ref": "main"}, "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"}, "base": {"ref": "main"}, "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 == []

View File

@@ -315,10 +315,17 @@ class _FakeResp:
def test_tc16_pr_already_merged_true(monkeypatch):
"""A merged PR -> True so a re-driven/reaped task is a no-op (no second merge)."""
"""A merged code-PR -> True so a re-driven/reaped task is a no-op (no second merge).
ORCH-073 FR-2: the guard now counts a PR only when it carries THIS branch's code
into main (merged & head.ref==branch & base.ref=="main").
"""
monkeypatch.setattr(
httpx, "get",
lambda *a, **k: _FakeResp(200, [{"number": 7, "merged": True}]),
lambda *a, **k: _FakeResp(
200,
[{"number": 7, "merged": True, "head": {"ref": "feature/x"}, "base": {"ref": "main"}}],
),
)
assert merge_gate.pr_already_merged("orchestrator", "feature/x") is True

View File

@@ -58,6 +58,11 @@ def race_repo(tmp_path, monkeypatch):
monkeypatch.setattr(qg.settings, "merge_gate_enabled", True)
monkeypatch.setattr(qg.settings, "merge_gate_repos", repo)
monkeypatch.setattr(merge_gate.settings, "merge_lock_timeout_s", 300)
# ORCH-026: this redrive test asserts the ORCH-043 ancestor-based short-circuit
# ("already caught up" -> skip expensive re-test). Pin the always-rebase
# kill-switch OFF so the legacy short-circuit path is exercised here; the new
# default (True) is covered by tests/test_orch026_premerge_rebase.py (TC-A01).
monkeypatch.setattr(qg.settings, "premerge_rebase_always", False, raising=False)
origin = tmp_path / "origin.git"
subprocess.run(["git", "init", "--bare", "-b", "main", str(origin)], capture_output=True)
@@ -204,7 +209,9 @@ def test_tc17_pr_already_merged_makes_redrive_a_noop(race_repo, monkeypatch):
@staticmethod
def json():
return [{"merged": True}]
# ORCH-073 FR-2: the guard counts a PR only when it carries THIS branch's
# code into main (merged & head.ref==branch & base.ref=="main").
return [{"merged": True, "head": {"ref": "feature/B"}, "base": {"ref": "main"}}]
monkeypatch.setattr(httpx, "get", lambda *a, **k: _R())
assert merge_gate.pr_already_merged(repo, "feature/B") is True

133
tests/test_merge_verify.py Normal file
View File

@@ -0,0 +1,133 @@
"""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 (ORCH-073 FR-1): PR.merged==true NO LONGER confirms a merge. The former
# OR-branch was the phantom-merge root cause (a merged docs-PR turned verify green).
# SHA-in-main is now the SINGLE criterion; an empty SHA -> inconclusive -> False.
# ---------------------------------------------------------------------------
def test_tc02_pr_merged_does_not_confirm_without_sha_in_main(monkeypatch):
# Even if a (docs-)PR is reported merged, that must NOT short-circuit to True.
monkeypatch.setattr(merge_gate, "pr_already_merged", lambda r, b: True)
monkeypatch.setattr(merge_gate, "ensure_worktree", lambda r, b: "/wt")
# SHA not an ancestor of origin/main (rc=1) -> not confirmed despite merged PR.
monkeypatch.setattr(
merge_gate.subprocess, "run",
lambda cmd, *a, **k: _R(1) if "merge-base" in cmd else _R(0),
)
assert merge_gate.verify_merged_to_main("orchestrator", "feature/ORCH-071-x", "abc123") is False
# And an empty SHA is inconclusive -> False (cannot prove SHA-in-main).
assert merge_gate.verify_merged_to_main("orchestrator", "feature/ORCH-071-x", "") is False
# ---------------------------------------------------------------------------
# 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_worktree_error(monkeypatch):
# ORCH-073: verify no longer consults pr_already_merged; a worktree/git error
# on the SHA-in-main path is the failure to swallow -> conservative False.
def boom(*a, **k):
raise RuntimeError("worktree exploded")
monkeypatch.setattr(merge_gate, "ensure_worktree", 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

@@ -0,0 +1,118 @@
"""ORCH-026 conditionality / self-hosting safety (TC-A06, TC-A07).
TC-A06 kill-switch / out-of-scope: with the flag off (or for a repo outside the
merge-gate scope) the merge path behaves 1:1 as before ORCH-026 — no-op.
TC-A07 self-hosting safety: the new Level-A logic never pushes to main; the only
force op stays --force-with-lease on the task branch; STAGE_TRANSITIONS
and the QG_CHECKS registry are unchanged.
"""
import os
import tempfile
os.environ.setdefault("ORCH_DB_PATH", os.path.join(tempfile.gettempdir(), "test_orch026_cond.db"))
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
from src import merge_gate # noqa: E402
from src.qg import checks # noqa: E402
# ----------------------------------------------------------------- TC-A06
def test_out_of_scope_repo_is_noop_even_with_flag_on(monkeypatch):
"""A repo outside merge_gate scope -> N/A pass, regardless of premerge flag."""
monkeypatch.setattr(checks.settings, "merge_gate_enabled", True, raising=False)
monkeypatch.setattr(checks.settings, "merge_gate_repos", "orchestrator", raising=False)
monkeypatch.setattr(checks.settings, "premerge_rebase_always", True, raising=False)
# enduro-trails is NOT in the scope -> no lease, no rebase, just N/A.
called = {"acquire": 0, "rebase": 0}
monkeypatch.setattr(merge_gate, "acquire_merge_lease",
lambda *a, **k: (called.__setitem__("acquire", called["acquire"] + 1), (True, "x"))[1],
raising=False)
monkeypatch.setattr(merge_gate, "auto_rebase_onto_main",
lambda *a, **k: (called.__setitem__("rebase", called["rebase"] + 1), (True, "x"))[1],
raising=False)
ok, reason = checks.check_branch_mergeable("enduro-trails", "ET-1", "feature/e")
assert ok is True
assert "N/A" in reason
assert called["acquire"] == 0 and called["rebase"] == 0
def test_task_deps_kill_switch_omits_gate(monkeypatch):
"""task_deps_enabled=False -> claim_next_job query is the ORCH-1 query (no gate)."""
import src.db as db
monkeypatch.setattr(db.settings, "task_deps_enabled", False, raising=False)
# Inspect the SQL the claim builds by stubbing the connection.
captured = {}
class _FakeConn:
def execute(self, sql, *a):
captured.setdefault("sql", sql)
class _R:
def fetchone(self_inner):
return None
return _R()
def commit(self):
pass
def close(self):
pass
monkeypatch.setattr(db, "get_db", lambda: _FakeConn())
db.claim_next_job()
assert "NOT EXISTS" not in captured["sql"], "gate must be omitted when disabled"
def test_task_deps_enabled_adds_gate(monkeypatch):
import src.db as db
monkeypatch.setattr(db.settings, "task_deps_enabled", True, raising=False)
captured = {}
class _FakeConn:
def execute(self, sql, *a):
captured.setdefault("sql", sql)
class _R:
def fetchone(self_inner):
return None
return _R()
def commit(self):
pass
def close(self):
pass
monkeypatch.setattr(db, "get_db", lambda: _FakeConn())
db.claim_next_job()
assert "NOT EXISTS" in captured["sql"], "gate must be present when enabled"
assert "job_deps" in captured["sql"]
# ----------------------------------------------------------------- TC-A07
def test_stage_transitions_unchanged():
"""ORCH-026 must not touch the state machine (AC-A5)."""
from src.stages import STAGE_TRANSITIONS
# The canonical happy-path edges must still exist exactly.
assert STAGE_TRANSITIONS["deploy-staging"]["next"] == "deploy"
assert STAGE_TRANSITIONS["deploy"]["next"] == "done"
assert STAGE_TRANSITIONS["development"]["next"] == "review"
def test_qg_registry_has_no_new_dep_gate():
"""The dependency gate is врезка in claim_next_job, NOT a registered QG."""
from src.qg.checks import QG_CHECKS
joined = " ".join(QG_CHECKS.keys())
assert "task_dep" not in joined and "dependency" not in joined
def test_premerge_only_force_with_lease_on_branch():
"""auto_rebase_onto_main never pushes to main; force is --force-with-lease only."""
import inspect
src = inspect.getsource(merge_gate.auto_rebase_onto_main)
assert "--force-with-lease" in src
# No raw 'push origin main' / force-push to main in the rebase path.
assert "push origin main" not in src
assert "--force " not in src # plain --force (not -with-lease) is forbidden

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