Compare commits
25 Commits
feature/OR
...
feature/OR
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0cbb7ef0bb | ||
|
|
e07ee9e574 | ||
| 8cdb9f194a | |||
| cb3bdd9c7a | |||
|
|
04233cb3c8 | ||
|
|
85ecf50926 | ||
| 30b6187c73 | |||
| 44db94e462 | |||
| 4f24f96169 | |||
| 2d20da295e | |||
| 67e98b8296 | |||
|
|
cad5e98892 | ||
| bb03350ec9 | |||
| 930e65298c | |||
| cba67a4270 | |||
| 720c31393a | |||
| 9b7c855df3 | |||
| a6b444c356 | |||
| dbf14e3d5a | |||
| 4bebb921ff | |||
| 9f846b5a50 | |||
| b760b24a48 | |||
| f0ac9d5562 | |||
| 987ea810bf | |||
| f85e449d80 |
50
.env.example
50
.env.example
@@ -117,6 +117,56 @@ ORCH_RECONCILE_GRACE_OVERRIDES_JSON=
|
||||
ORCH_RECONCILE_NOTIFY_UNBLOCK=true
|
||||
ORCH_RECONCILE_SKIP_BLOCKED_ENABLED=true
|
||||
|
||||
# ORCH-065: job-reaper + proactive merge-lease reclaim. A background daemon thread
|
||||
# (src/job_reaper.py, started LAST in main.lifespan after requeue_running_jobs) reaps
|
||||
# zombie 'running' jobs whose monitor/process died before writing the terminal status
|
||||
# (one zombie at max_concurrency=1 blocks the whole shared queue) and periodically
|
||||
# reclaims dead/stale merge-leases. Liveness is three-tier: Tier-1 dead jobs.pid
|
||||
# (os.kill(pid,0)) after REAPER_DEAD_TICKS consecutive dead ticks (anti-false-positive
|
||||
# for a live agent); Tier-2 agent_runs.exit_code recorded but job still 'running'
|
||||
# (only after a REAPER_FINALIZE_GRACE_S finalization grace, so a live monitor still
|
||||
# doing git push / PR / Plane comments is never reaped); Tier-3 backstop after
|
||||
# REAPER_MAX_RUNNING_S. The terminal flip carries an atomic status='running' guard and
|
||||
# precedes any advance/enqueue (claim-before-act) so it never double-processes/-advances
|
||||
# a row racing a late monitor or requeue_running_jobs.
|
||||
# REAPER_ENABLED -> global kill-switch (false -> strictly prior behaviour).
|
||||
# REAPER_INTERVAL_S -> background scan period (seconds).
|
||||
# REAPER_DEAD_TICKS -> consecutive dead-pid ticks before reaping (Tier-1, >=2).
|
||||
# REAPER_MAX_RUNNING_S -> Tier-3 backstop ceiling; must exceed max agent_timeout+grace.
|
||||
# REAPER_FINALIZE_GRACE_S -> Tier-2 grace: how long agent_runs.exit_code must have been
|
||||
# recorded before a still-'running' job is reaped; MUST exceed
|
||||
# the max finalization window (git push + PR + Plane comments).
|
||||
# LEASE_RECLAIM_ENABLED -> kill-switch for the proactive stale/dead lease reclaim
|
||||
# (false -> only the legacy lazy TTL reclaim in acquire_merge_lease).
|
||||
# (reuse) ORCH_MERGE_LOCK_TIMEOUT_S -> lease TTL; ORCH_MERGE_GATE_REPOS -> reclaim scope.
|
||||
ORCH_REAPER_ENABLED=true
|
||||
ORCH_REAPER_INTERVAL_S=60
|
||||
ORCH_REAPER_DEAD_TICKS=2
|
||||
ORCH_REAPER_MAX_RUNNING_S=3600
|
||||
ORCH_REAPER_FINALIZE_GRACE_S=300
|
||||
ORCH_LEASE_RECLAIM_ENABLED=true
|
||||
|
||||
# ORCH-022: security-gate (secret-scanning + dependency audit) on the
|
||||
# deploy-staging -> deploy edge, run FIRST among the edge sub-gates. Deterministic
|
||||
# (no LLM): gitleaks (offline secret-scan, pinned Go binary in the image) + pip-audit
|
||||
# (OSV/PyPI CVE audit). Verdict in the versioned 17-security-report.md frontmatter;
|
||||
# FAIL -> rollback to development + developer-retry (cap 3). See ADR-001.
|
||||
# GATE_ENABLED -> global kill-switch; false -> pipeline 1:1 as before ORCH-022.
|
||||
# GATE_REPOS -> CSV of repos where the gate is REAL; empty -> only self-hosting.
|
||||
# DEP_BLOCK_SEVERITY -> CVE severity that BLOCKS (CRITICAL>HIGH>MEDIUM>LOW); below /
|
||||
# UNKNOWN -> warning only (anti-loop).
|
||||
# SCAN_TIMEOUT_S -> per external scanner call timeout.
|
||||
# DEP_AUDIT_FAIL_CLOSED -> strict mode: unreachable CVE feed -> FAIL instead of the
|
||||
# default fail-open + warning (anti-loop). Default false.
|
||||
# SECRETS_BLOCK -> a found secret blocks (always true by default; the offline
|
||||
# secrets guarantee is unconditional).
|
||||
ORCH_SECURITY_GATE_ENABLED=true
|
||||
ORCH_SECURITY_GATE_REPOS=
|
||||
ORCH_SECURITY_DEP_BLOCK_SEVERITY=HIGH
|
||||
ORCH_SECURITY_SCAN_TIMEOUT_S=300
|
||||
ORCH_SECURITY_DEP_AUDIT_FAIL_CLOSED=false
|
||||
ORCH_SECURITY_SECRETS_BLOCK=true
|
||||
|
||||
# ORCH-021: post-deploy production monitoring + degradation reaction. After the
|
||||
# terminal deploy->done transition for an applicable repo, a reserved-agent job
|
||||
# `post-deploy-monitor` (no LLM, modelled on deploy-finalizer) probes prod over a
|
||||
|
||||
38
.gitleaks.toml
Normal file
38
.gitleaks.toml
Normal file
@@ -0,0 +1,38 @@
|
||||
# gitleaks config — ORCH-022 security-gate (secret-scanning).
|
||||
#
|
||||
# Versioned in the repo root (07-infra I-4 / BR-13): rules + an allowlist of
|
||||
# known-safe matches are reviewed as code. The security-gate (src/security_gate.py)
|
||||
# passes this file via `--config` when present. gitleaks runs OFFLINE (local rules)
|
||||
# so the "a secret always blocks" guarantee (BR-2) never depends on the network.
|
||||
#
|
||||
# Strategy: extend the built-in ruleset (broad coverage, maintained upstream) and
|
||||
# only ADD a narrow allowlist for placeholders / fixtures that are intentionally
|
||||
# fake (e.g. .env.example dummy values, test fixtures). Keep the allowlist tight —
|
||||
# an over-broad allowlist silently re-opens the leak it was meant to bless.
|
||||
|
||||
title = "orchestrator gitleaks config"
|
||||
|
||||
[extend]
|
||||
# Start from gitleaks' maintained default ruleset.
|
||||
useDefault = true
|
||||
|
||||
[allowlist]
|
||||
description = "Known-safe, intentionally non-secret matches (placeholders + fixtures)."
|
||||
|
||||
# Files that legitimately contain placeholder/dummy secret-shaped values:
|
||||
# * .env.example — the committed canon of env vars with DUMMY values (CLAUDE.md §8;
|
||||
# real secrets live only in the host .env / .env.staging, never in git).
|
||||
# * tests/ — fixtures may embed fake tokens to exercise the scanner itself (TC-03).
|
||||
# * .gitleaks.toml — this file (avoid self-matching example patterns below).
|
||||
paths = [
|
||||
'''(^|/)\.env\.example$''',
|
||||
'''(^|/)tests/''',
|
||||
'''(^|/)\.gitleaks\.toml$''',
|
||||
]
|
||||
|
||||
# Generic placeholder tokens used in docs / examples that are NOT real secrets.
|
||||
regexes = [
|
||||
'''(?i)(your[-_]?(token|key|secret|password)[-_]?here)''',
|
||||
'''(?i)(changeme|dummy|example|placeholder|xxxxx+)''',
|
||||
'''(?i)<[a-z0-9_-]+>''',
|
||||
]
|
||||
@@ -91,6 +91,30 @@ The verdict contract is unchanged: `docs/work-items/<work_item_id>/14-deploy-log
|
||||
frontmatter field `deploy_status: SUCCESS|FAILED` (the gate `check_deploy_status` parses ONLY this).
|
||||
**What changed (ORCH-36): WHO and WHEN writes that verdict, for the self-hosting repo.**
|
||||
|
||||
### ⚠️ Idempotent merge guard — consult `pr_already_merged` BEFORE merging (ORCH-065)
|
||||
|
||||
The `deploy` stage can be **re-driven**: if a process/monitor thread died after the PR
|
||||
merged but before the job finalised, the job-reaper requeues it and this stage runs **again**
|
||||
(ADR-001 ORCH-065, Р-3). A blind second merge of an already-merged PR makes Gitea return a
|
||||
merge error → a false БАГ-8 rollback. To stay idempotent, **before you merge the feature
|
||||
branch PR into `main`, consult the deterministic guard** `merge_gate.pr_already_merged(repo, branch)`:
|
||||
|
||||
```bash
|
||||
# Already merged? exit 0 = yes (skip the merge), exit 1 = no (merge normally).
|
||||
python3 -c "import sys; from src.merge_gate import pr_already_merged; \
|
||||
sys.exit(0 if pr_already_merged('<repo>', '<branch>') else 1)" && MERGED=1 || MERGED=0
|
||||
```
|
||||
|
||||
- `MERGED=1` (PR already merged) → **do NOT merge again** (no second merge, no error).
|
||||
Treat the merge as already done and continue to write the deploy verdict
|
||||
(`deploy_status: SUCCESS` once the deploy itself is health-ok). This is the AC-11 no-op.
|
||||
- `MERGED=0` (not merged) → merge the PR normally, then proceed.
|
||||
|
||||
The guard is **never-raise** (any Gitea/parse error → `False` → "not known-merged", so a real
|
||||
merge is never silently skipped). This is the single consultation point ADR-001 Р-3 /
|
||||
README / CHANGELOG refer to: the **merge path (deployer/merge) consults the guard before a
|
||||
(repeat) merge**.
|
||||
|
||||
### Self-hosting repo (`orchestrator`) — you do NOT deploy yourself
|
||||
|
||||
For `orchestrator` the `deploy` stage is orchestrated by **deterministic code** in
|
||||
@@ -124,4 +148,7 @@ deploys go through `scripts/orchestrator-deploy-hook.sh` (parametrised; defaults
|
||||
|
||||
- Always write machine-readable YAML frontmatter — the quality gates parse ONLY the frontmatter fields, never the body prose.
|
||||
- Never push directly to `main`. Always use a PR or the artifact merge pattern.
|
||||
- **Idempotent merge (ORCH-065):** before any (re-)merge of a feature PR into `main`, consult
|
||||
`merge_gate.pr_already_merged(repo, branch)` (see the `deploy` stage section). Already merged
|
||||
→ no second merge, no error — the stage is a no-op on the merge and proceeds to its verdict.
|
||||
- Never modify `.env`, `.env.staging`, `docker-compose.yml`, or production infrastructure.
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -44,10 +44,10 @@ created → analysis → architecture → development → review → testing →
|
||||
- ADR per work-item: `docs/work-items/<plane-id>/06-adr/ADR-NNN-slug.md`
|
||||
- Global ADR (сквозные решения): `docs/architecture/adr/adr-NNNN-slug.md`
|
||||
- Work items: `docs/work-items/<plane-id>/`
|
||||
- Машинные вердикты Quality Gate — строго YAML-frontmatter (`verdict:`, `deploy_status:`, `staging_status:`), никогда проза
|
||||
- Машинные вердикты Quality Gate — строго YAML-frontmatter (`verdict:`, `deploy_status:`, `staging_status:`, `security_status:`), никогда проза
|
||||
|
||||
## Артефакты задачи (`docs/work-items/<plane-id>/`)
|
||||
`00-business-request.md`, `01-brd.md`, `02-trz.md`, `03-acceptance-criteria.md`, `04-test-plan.yaml`, `06-adr/ADR-NNN-slug.md`, `07-infra-requirements.md`, `08-data-requirements.md`, `10-tech-risks.md`, `12-review.md`, `13-test-report.md`, `14-deploy-log.md`, `15-staging-log.md`, `16-post-deploy-log.md` (post-deploy наблюдение, ORCH-021).
|
||||
`00-business-request.md`, `01-brd.md`, `02-trz.md`, `03-acceptance-criteria.md`, `04-test-plan.yaml`, `06-adr/ADR-NNN-slug.md`, `07-infra-requirements.md`, `08-data-requirements.md`, `10-tech-risks.md`, `12-review.md`, `13-test-report.md`, `14-deploy-log.md`, `15-staging-log.md`, `16-post-deploy-log.md` (post-deploy наблюдение, ORCH-021), `17-security-report.md` (security-гейт: `security_status:`/secrets/deps, ORCH-022).
|
||||
|
||||
## Правила для агентов
|
||||
1. Перед любым действием прочесть этот файл и `docs/architecture/README.md`.
|
||||
|
||||
21
Dockerfile
21
Dockerfile
@@ -8,9 +8,28 @@ FROM python:3.12-slim
|
||||
ARG GIT_SHA=""
|
||||
LABEL org.opencontainers.image.revision=$GIT_SHA
|
||||
WORKDIR /app
|
||||
RUN apt-get update -qq && apt-get install -y -qq openssh-client git && rm -rf /var/lib/apt/lists/*
|
||||
RUN apt-get update -qq && apt-get install -y -qq openssh-client git curl ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||
# git operations run as root over bind-mounted /repos (may be owned by host uid) -> trust it.
|
||||
RUN git config --system --add safe.directory '*'
|
||||
# ORCH-022: pinned gitleaks static Go binary for the offline secret-scan sub-gate
|
||||
# (07-infra I-1). Baked into the image (NOT a pip package): the gate runs INSIDE the
|
||||
# orchestrator container over a per-task worktree. Pinned release => deterministic
|
||||
# rules; gitleaks needs no network so the "a secret always blocks" guarantee (BR-2)
|
||||
# is independent of internet access. Multi-arch aware (amd64/arm64).
|
||||
ARG GITLEAKS_VERSION=8.18.4
|
||||
RUN set -eux; \
|
||||
arch="$(dpkg --print-architecture)"; \
|
||||
case "$arch" in \
|
||||
amd64) gl_arch="x64" ;; \
|
||||
arm64) gl_arch="arm64" ;; \
|
||||
*) echo "unsupported arch: $arch" >&2; exit 1 ;; \
|
||||
esac; \
|
||||
curl -fsSL -o /tmp/gitleaks.tar.gz \
|
||||
"https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_${gl_arch}.tar.gz"; \
|
||||
tar -xzf /tmp/gitleaks.tar.gz -C /usr/local/bin gitleaks; \
|
||||
chmod +x /usr/local/bin/gitleaks; \
|
||||
rm -f /tmp/gitleaks.tar.gz; \
|
||||
gitleaks version
|
||||
# ORCH-58: compose runs the container as uid:gid 1000:1000 (ORCH-40), but the base
|
||||
# image has no passwd entry for uid 1000 -> ssh/whoami fail with
|
||||
# "No user exists for uid 1000" (rc=255), breaking the detached self-deploy ssh
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
- **Quality Gates** (`src/qg/checks.py`) — проверки выхода со стадии, реестр `QG_CHECKS`.
|
||||
- **Agent Launcher** (`src/agents/launcher.py`) — запуск Claude CLI агентов в изолированном git worktree, мониторинг, auto-advance.
|
||||
- **Queue** (`src/queue_worker.py`, ORCH-1) — персистентная очередь задач (SQLite `jobs`), atomic claim, max_concurrency, ретраи, restart-safe.
|
||||
- **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`.
|
||||
- **Project Registry** (`src/projects.py`, ORCH-6) — Plane project id → repo + prefix; фильтрация вебхуков по проекту.
|
||||
- **Plane Sync** (`src/plane_sync.py`) — синхронизация статусов/комментариев в Plane.
|
||||
@@ -35,7 +36,7 @@ created → analysis → architecture → development → review → testing →
|
||||
| deploy | — | `check_deploy_status` | 14-deploy-log.md (`deploy_status:`) |
|
||||
| done | — | — | — |
|
||||
|
||||
**Реестр QG** (`QG_CHECKS`): check_analysis_approved, check_analysis_complete, check_architecture_done, check_ci_green, check_review_approved, check_tests_passed, check_reviewer_verdict, check_tests_local, check_deploy_status, check_staging_status, check_branch_mergeable (ORCH-043), check_staging_image_fresh (ORCH-058).
|
||||
**Реестр QG** (`QG_CHECKS`): check_analysis_approved, check_analysis_complete, check_architecture_done, check_ci_green, check_review_approved, check_tests_passed, check_reviewer_verdict, check_tests_local, check_deploy_status, check_staging_status, check_branch_mergeable (ORCH-043), check_staging_image_fresh (ORCH-058), check_security_gate (ORCH-022).
|
||||
|
||||
**Канон гейтов:** машинные вердикты читаются ТОЛЬКО из YAML-frontmatter, никогда из прозы. Лог-файлы мержатся в `origin/main` отдельным PR; гейт читает из `origin/main`.
|
||||
|
||||
@@ -154,6 +155,38 @@ helper `validated_revision` питает и штамп A, и `EXPECTED_REVISION`
|
||||
образа, без миграций). Подробнее: [adr-0008](adr/adr-0008-staging-image-provenance.md),
|
||||
детально — `docs/work-items/ORCH-058/06-adr/ADR-001-staging-image-provenance.md`.
|
||||
|
||||
### Security-гейт: secret-scanning + dependency audit перед мержем (ORCH-022 — реализовано)
|
||||
Автономный конвейер вливал ветку в `main` без проверки на утёкший секрет (ключ/токен/пароль/
|
||||
приватный ключ) и уязвимую зависимость (CVE); для self-hosting один секрет/CVE через одну
|
||||
задачу уезжал в общий прод всех проектов (CLAUDE.md §8). ORCH-022 вводит детерминированный
|
||||
(без LLM) **security-гейт как под-гейт ребра `deploy-staging → deploy`**, рядом с merge-gate
|
||||
(ORCH-043) и image-freshness (ORCH-058), исполняемый **ПЕРВЫМ** среди edge-под-гейтов
|
||||
(ДО merge-gate). Паттерн соседей: leaf `src/security_gate.py` (never-raise) + тонкая обёртка
|
||||
`check_security_gate` в `QG_CHECKS` + врезка `_handle_security_gate` в `advance_stage`.
|
||||
`STAGE_TRANSITIONS` и схема БД — **без изменений**.
|
||||
- **Secret-scanning (`gitleaks`, offline):** скан `origin/main..HEAD`; любой секрет вне
|
||||
аллоулиста `.gitleaks.toml` → вклад в FAIL. Offline → гарантия «секрет всегда блокирует»
|
||||
не зависит от сети (безусловна).
|
||||
- **Dependency audit (`pip-audit`, OSV/PyPI):** severity ≥ `security_dep_block_severity`
|
||||
(дефолт `HIGH`) → FAIL; ниже / UNKNOWN → warning. Недоступность фида → **fail-open +
|
||||
громкий warning** (анти-петля ORCH-061; флаг `security_dep_audit_fail_closed` для строгого
|
||||
режима). best-effort при доступности фида.
|
||||
- **ПЕРВЫМ, ДО merge-gate:** дёшево фейлить до дорогих rebase/rebuild; скан ветки ДО rebase
|
||||
не «обвиняет» задачу в CVE из обновившегося `main`; до захвата merge-lease → при FAIL lease
|
||||
освобождать не нужно.
|
||||
- **Артефакт `17-security-report.md`** (YAML-frontmatter `security_status`/`secrets_found`/
|
||||
`deps_blocking`/`deps_warning`/`deps_audit_degraded`); вердикт читается ТОЛЬКО из
|
||||
frontmatter (гейт пишет → читает обратно через `parse_security_status` → возвращает: единый
|
||||
источник истины), negative-токен авторитетен, битый/нет → fail-closed.
|
||||
- **FAIL → откат на `development`** + developer-retry (общий `_developer_retry_count`, cap 3,
|
||||
затем `set_issue_blocked` + Telegram); `task_desc` несёт дословные находки (ORCH-046).
|
||||
- **Условность как ORCH-35/43/58:** `security_gate_enabled` + `security_gate_repos` (пусто →
|
||||
только self-hosting); never-raise; таймаут `security_scan_timeout_s`; гейт не деплоит/не
|
||||
рестартит прод. v1 — Python-only; SAST/мульти-стек — follow-up (BR-14).
|
||||
|
||||
Подробнее: [adr-0012](adr/adr-0012-security-gate.md), детально —
|
||||
`docs/work-items/ORCH-022/06-adr/ADR-001-security-gate.md`.
|
||||
|
||||
### Reconciler: реконсиляция потерянных webhook (ORCH-053 — реализовано)
|
||||
Конвейер продвигается только входящими webhook; потерянное событие (502 на ребилде,
|
||||
нет ретраев у Plane/Gitea, неразрезолвленный `sha→branch`) → задача застревает молча
|
||||
@@ -190,6 +223,64 @@ never-raise на единицу работы; тишина при синхрон
|
||||
и реестры (`STAGE_TRANSITIONS`/`QG_CHECKS`) не меняются. Подробнее:
|
||||
[adr-0007](adr/adr-0007-reconciler.md), детально — `docs/work-items/ORCH-053/06-adr/ADR-001-stuck-task-reconciler.md`.
|
||||
|
||||
### Job-reaper + проактивный реклейм merge-lease (ORCH-065 — design)
|
||||
Финализация статуса job (`done`/`queued`/`failed`) выполняется ТОЛЬКО в
|
||||
`launcher._monitor_agent → _finalize_job` внутри живого процесса. Смерть
|
||||
monitor-потока/процесса между `proc.wait()` и `_finalize_job` (краш, OOM,
|
||||
self-restart во время deploy) оставляла строку `jobs` навсегда `running`; при
|
||||
`max_concurrency=1` одна зомби-строка блокирует claim всех job → встаёт конвейер
|
||||
ВСЕХ проектов (инциденты 07.06: jobs 236/239/242/254). `requeue_running_jobs()`
|
||||
спасал ТОЛЬКО на старте процесса. Симметрично залипал merge-lease (ORCH-043):
|
||||
реклейм был лениво-по-TTL и только при чужом `acquire`, liveness держателя по pid
|
||||
не проверялся. Это последняя ручная точка автономного self-deploy (блокер ORCH-54).
|
||||
ORCH-065 вводит фоновый watchdog, чтобы смерть процесса/потока на любой стадии НЕ
|
||||
оставляла навсегда захваченных ресурсов:
|
||||
- **Job-reaper** (`src/job_reaper.py`) — daemon-поток по образцу `reconciler`,
|
||||
работает **без рестарта**. Трёхуровневая liveness: Tier-1 мёртвый `jobs.pid`
|
||||
(новая колонка) после `reaper_dead_ticks` подряд тиков (анти-ложноположительность
|
||||
— живой долгий агент не реапится); Tier-2 `agent_runs.exit_code` записан, а job
|
||||
ещё `running` — но это окно неоднозначно (живой monitor пишет exit_code ПЕРВЫМ,
|
||||
затем git push/PR/Plane-комментарии), поэтому Tier-2 реапит только после
|
||||
finalization-grace `reaper_finalize_grace_s` (живой финализирующий monitor НЕ
|
||||
реапится); Tier-3 backstop по потолку `reaper_max_running_s` (> max
|
||||
agent_timeout+grace). Действие переиспользует контракты по принципу
|
||||
**claim-before-act**: для exit0 канонический QG оценивается read-only ПЕРЕД
|
||||
атомарным claim, затем claim `done` ПЕРВЫМ и только победитель claim делает
|
||||
`_try_advance_stage` (advance+enqueue) — проигравший claim (поздний monitor /
|
||||
стартовый requeue) не выполняет побочных эффектов (нет дубль-advance/-enqueue);
|
||||
источник истины — канонический QG, не факт «exit0»; гейт красный или exit≠0/
|
||||
неизвестно → `attempts<max`→`queued`, иначе `failed`+Telegram. Атомарный
|
||||
reap-claim (`UPDATE ... WHERE id=? AND status='running'`) совместим со стартовым
|
||||
`requeue_running_jobs` (restart-safe, без двойной обработки).
|
||||
- **Проактивный реклейм stale/dead lease** (функции в `merge_gate.py`:
|
||||
`pid_alive`, `reclaim_stale_lease`) — на старте (рядом с `requeue_running_jobs`)
|
||||
и периодически из тика reaper: освобождает lease, чей держатель **мёртв** (pid
|
||||
не жив) ИЛИ **просрочен** (TTL `merge_lock_timeout_s`); живой держатель в
|
||||
пределах TTL — НЕ трогать (защита легитимного merge). holder-aware, never-raise,
|
||||
условность как ORCH-43 (`merge_gate_repos`/self-hosting).
|
||||
- **Идемпотентная финализация merge** — без новой merge-логики: re-drive через
|
||||
reaper→`queued`→переисполнение стадии / reconciler; дорогие шаги не повторяются
|
||||
(`branch_is_behind_main==False`); добавлен never-raise guard `pr_already_merged`
|
||||
(читает состояние PR) — уже слит = no-op. **Консультируется самим merge-актором:**
|
||||
фактический merge PR в `main` делает агент `deployer` (в начале стадии `deploy`),
|
||||
поэтому wiring — в его промпте `.openclaw/agents/deployer.md`, который вызывает
|
||||
`pr_already_merged` ПЕРЕД любым (повторным) merge (AC-11). Чек `check_branch_mergeable`
|
||||
НЕ меняется (AC-13): он на ПЕРВОМ ребре `deploy-staging → deploy`, а риск второго
|
||||
merge — на re-drive самой стадии `deploy`.
|
||||
- **Схема БД:** единственное изменение — `jobs.pid INTEGER` через идемпотентный
|
||||
`_ensure_column` (live-safe). `STAGE_TRANSITIONS`, `QG_CHECKS`, `check_*`, БАГ-8,
|
||||
exit-коды хука, файл-схема lease — без изменений.
|
||||
- **Наблюдаемость:** блок `reaper` в `GET /queue` (enabled, interval, last_run_ts,
|
||||
reaped_total, last_reaped, lease_reclaimed_total); каждый reap/lease-reclaim →
|
||||
`logger.warning`; reap→`failed` и lease-reclaim → Telegram.
|
||||
- **Kill-switch'и:** `ORCH_REAPER_ENABLED`, `ORCH_REAPER_INTERVAL_S`,
|
||||
`ORCH_REAPER_DEAD_TICKS`, `ORCH_REAPER_MAX_RUNNING_S`,
|
||||
`ORCH_REAPER_FINALIZE_GRACE_S`, `ORCH_LEASE_RECLAIM_ENABLED`; `false` → строго
|
||||
прежнее поведение.
|
||||
|
||||
Подробнее: [adr-0011](adr/adr-0011-job-reaper-lease-reclaim.md), детально —
|
||||
`docs/work-items/ORCH-065/06-adr/ADR-001-job-reaper-and-lease-reclaim.md`.
|
||||
|
||||
## Откаты
|
||||
- Reviewer REQUEST_CHANGES → откат на `development` + retry (`MAX_DEVELOPER_RETRIES = 3`).
|
||||
- Tester `check_tests_passed` FAIL → откат на `development` + retry.
|
||||
@@ -223,7 +314,7 @@ never-raise на единицу работы; тишина при синхрон
|
||||
- `events` — входящие вебхуки (дедуп)
|
||||
- `tasks` — задачи и их стадии
|
||||
- `agent_runs` — запуски агентов (run_id, usage, cost)
|
||||
- `jobs` — очередь задач (ORCH-1)
|
||||
- `jobs` — очередь задач (ORCH-1); колонка `pid` (ORCH-065) — pid агентского процесса для liveness-детекции зомби job-reaper'ом
|
||||
|
||||
## Изоляция (git worktree, ORCH-2)
|
||||
Каждая задача исполняется в отдельном git worktree, ветки не пересекаются. Репозитории проектов разделены под `/repos/<project>`.
|
||||
@@ -233,7 +324,7 @@ never-raise на единицу работы; тишина при синхрон
|
||||
|--------|------|----------|
|
||||
| GET | `/health` | health check |
|
||||
| GET | `/status` | активные задачи (stage != done) |
|
||||
| GET | `/queue` | очередь: counts + max_concurrency + resilience + reconcile (ORCH-053) + post_deploy (ORCH-021) + последние jobs |
|
||||
| GET | `/queue` | очередь: counts + max_concurrency + resilience + reconcile (ORCH-053) + reaper (ORCH-065) + post_deploy (ORCH-021) + последние jobs |
|
||||
| POST | `/webhook/plane` | Plane webhook |
|
||||
| POST | `/webhook/gitea` | Gitea webhook (push, PR, CI status) |
|
||||
|
||||
@@ -247,4 +338,4 @@ never-raise на единицу работы; тишина при синхрон
|
||||
Схема БД, потоки данных, resilience-слой, детали Dockerfile — [internals.md](internals.md).
|
||||
|
||||
---
|
||||
*Актуально на 2026-06-07. Обновлять при изменении src/stages.py, src/qg/checks.py, src/main.py. Статусы доработок: ORCH-036 (исполняемый самодеплой `deploy`, adr-0007) — реализовано; ORCH-043 (merge-gate, adr-0006) — design, ветка feature/ORCH-043; ORCH-053 (reconciler, adr-0007, src/reconciler.py) — реализовано; ORCH-060 (F-1 skip escalated/Blocked/Needs-Input, `docs/work-items/ORCH-060/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-060 (Guard 1 `developer_retry_count>=MAX_DEVELOPER_RETRIES` + Guard 2 `plane_sync.fetch_issue_state` Blocked/Needs-Input, флаг `ORCH_RECONCILE_SKIP_BLOCKED_ENABLED`); ORCH-058 (провенанс staging-образа: check_staging_image_fresh + staging_check свежего образа + хук-guard, adr-0008) — реализовано в ветке feature/ORCH-058 (обновлять также при изменении src/image_freshness.py, scripts/orchestrator-deploy-hook.sh, Dockerfile); ORCH-061 (толерантность staging-вердикта к инфра-FAIL C9a/C9b, adr-0009, `docs/work-items/ORCH-061/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-061 (обновлять также при изменении src/staging_verdict.py, scripts/staging_check.py, флаг staging_infra_tolerance_enabled); ORCH-021 (post-deploy наблюдение прода + реакция на деградацию, adr-0010, `docs/work-items/ORCH-021/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-021-post-deploy-rollback (reserved-agent job `post-deploy-monitor`: арм в src/stage_engine.py блок `next_stage == "done"`, тик `run_post_deploy_monitor` + перехват в src/agents/launcher.py ДО _spawn; чистая логика src/post_deploy.py never-raise; флаги `post_deploy_*` в src/config.py; блок `post_deploy` в `/queue`; артефакт 16-post-deploy-log.md; self-hosting всегда ALERT_ONLY — тик не рестартит прод; обновлять также при изменении src/post_deploy.py / арм-блока / launcher-перехвата).*
|
||||
*Актуально на 2026-06-07. Обновлять при изменении src/stages.py, src/qg/checks.py, src/main.py. Статусы доработок: ORCH-036 (исполняемый самодеплой `deploy`, adr-0007) — реализовано; ORCH-043 (merge-gate, adr-0006) — design, ветка feature/ORCH-043; ORCH-053 (reconciler, adr-0007, src/reconciler.py) — реализовано; ORCH-060 (F-1 skip escalated/Blocked/Needs-Input, `docs/work-items/ORCH-060/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-060 (Guard 1 `developer_retry_count>=MAX_DEVELOPER_RETRIES` + Guard 2 `plane_sync.fetch_issue_state` Blocked/Needs-Input, флаг `ORCH_RECONCILE_SKIP_BLOCKED_ENABLED`); ORCH-058 (провенанс staging-образа: check_staging_image_fresh + staging_check свежего образа + хук-guard, adr-0008) — реализовано в ветке feature/ORCH-058 (обновлять также при изменении src/image_freshness.py, scripts/orchestrator-deploy-hook.sh, Dockerfile); ORCH-061 (толерантность staging-вердикта к инфра-FAIL C9a/C9b, adr-0009, `docs/work-items/ORCH-061/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-061 (обновлять также при изменении src/staging_verdict.py, scripts/staging_check.py, флаг staging_infra_tolerance_enabled); ORCH-021 (post-deploy наблюдение прода + реакция на деградацию, adr-0010, `docs/work-items/ORCH-021/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-021-post-deploy-rollback (reserved-agent job `post-deploy-monitor`: арм в src/stage_engine.py блок `next_stage == "done"`, тик `run_post_deploy_monitor` + перехват в src/agents/launcher.py ДО _spawn; чистая логика src/post_deploy.py never-raise; флаги `post_deploy_*` в src/config.py; блок `post_deploy` в `/queue`; артефакт 16-post-deploy-log.md; self-hosting всегда ALERT_ONLY — тик не рестартит прод; обновлять также при изменении src/post_deploy.py / арм-блока / launcher-перехвата); ORCH-065 (job-reaper + проактивный реклейм merge-lease + идемпотентная финализация merge, adr-0011, `docs/work-items/ORCH-065/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-065 (новый daemon-поток src/job_reaper.py + старт/стоп в src/main.py lifespan; колонка `jobs.pid` через _ensure_column + проставление в src/agents/launcher.py `_spawn`; функции реклейма lease `pid_alive`/`reclaim_stale_lease` + guard `pr_already_merged` в src/merge_gate.py (консультируется merge-актором — промпт `.openclaw/agents/deployer.md`); флаги `reaper_*`/`lease_reclaim_*` в src/config.py; блок `reaper` в `/queue`; обновлять также при изменении этих мест); ORCH-022 (security-гейт: secret-scanning gitleaks + dependency audit pip-audit как под-гейт ребра `deploy-staging → deploy` ПЕРВЫМ, adr-0012, `docs/work-items/ORCH-022/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-022-security-secret-scanning (leaf src/security_gate.py never-raise + check_security_gate в src/qg/checks.py `QG_CHECKS` + врезка _handle_security_gate в src/stage_engine.py блок `current_stage == "deploy-staging"` ПЕРВОЙ; флаги `security_*` в src/config.py; gitleaks (pinned) в Dockerfile, pip-audit в requirements.txt, `.gitleaks.toml` в корне; артефакт 17-security-report.md; обновлять также при изменении этих мест).*
|
||||
|
||||
@@ -16,11 +16,12 @@ Per-work-item решения живут в `docs/work-items/<id>/06-adr/ADR-NNN-
|
||||
| adr-0008 | Провенанс staging-образа перед BUILD-ONCE retag | accepted | 2026-06-06 | ORCH-058 |
|
||||
| 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 |
|
||||
|
||||
> ⚠️ Историческая коллизия: номер `0007` занят двумя файлами —
|
||||
> `adr-0007-reconciler.md` (ORCH-053) и `adr-0007-executable-self-deploy.md`
|
||||
> (ORCH-036). Оба accepted; для новых сквозных ADR использовать следующий
|
||||
> свободный номер (текущий максимум — `0010`).
|
||||
> свободный номер (текущий максимум — `0011`).
|
||||
|
||||
## Формат
|
||||
**Контекст → Решение → Альтернативы → Последствия → Связи.** Статус: proposed / accepted / superseded.
|
||||
|
||||
82
docs/architecture/adr/adr-0011-job-reaper-lease-reclaim.md
Normal file
82
docs/architecture/adr/adr-0011-job-reaper-lease-reclaim.md
Normal file
@@ -0,0 +1,82 @@
|
||||
# adr-0011: Job-reaper + проактивный реклейм merge-lease
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Статус | accepted |
|
||||
| Дата | 2026-06-07 |
|
||||
| Источник | ORCH-065 (BUG P0, блокер ORCH-54) |
|
||||
| Детально | `docs/work-items/ORCH-065/06-adr/ADR-001-job-reaper-and-lease-reclaim.md` |
|
||||
|
||||
## Контекст
|
||||
|
||||
Единый инстанс с общей БД и очередью (`jobs`, `max_concurrency=1` для
|
||||
self-hosting). Финализация статуса job (`done`/`queued`/`failed`) происходит
|
||||
ТОЛЬКО в `launcher._monitor_agent → _finalize_job` внутри живого процесса. Смерть
|
||||
monitor-потока/процесса между `proc.wait()` и `_finalize_job` (краш, OOM,
|
||||
self-restart во время deploy) оставляет строку `jobs` навсегда `running`. При
|
||||
`max_concurrency=1` одна такая зомби-строка блокирует claim всех job →
|
||||
**встаёт конвейер всех проектов**. Единственная защита — `requeue_running_jobs()`
|
||||
— работает ТОЛЬКО на старте процесса. Симметрично: merge-lease (ORCH-043,
|
||||
файл `.merge-lease-<repo>.json`) реклеймится лишь лениво по TTL при чужом
|
||||
`acquire`; liveness держателя по pid не проверяется → залипший lease блокирует
|
||||
чужие merge. Это последняя ручная точка автономного self-deploy (блокер ORCH-54);
|
||||
доказанные инциденты 07.06 — jobs 236/239/242/254.
|
||||
|
||||
## Решение
|
||||
|
||||
1. **Job-reaper** — новый daemon-поток `src/job_reaper.py` (каркас `reconciler`:
|
||||
never-raise, `_stop`-Event, старт/стоп в `lifespan`, снимок в `/queue`,
|
||||
kill-switch). Работает **без рестарта** процесса. Liveness — трёхуровневая:
|
||||
Tier-1 мёртвый `jobs.pid` (новая колонка) после `reaper_dead_ticks` подряд
|
||||
тиков; Tier-2 `agent_runs.exit_code` записан, а job ещё `running` — но только
|
||||
после finalization-grace `reaper_finalize_grace_s` (окно неоднозначно: живой
|
||||
monitor пишет exit_code ПЕРВЫМ, затем git push/PR/Plane-комментарии, поэтому
|
||||
живой финализирующий monitor НЕ реапится); Tier-3 backstop по потолку
|
||||
`reaper_max_running_s`. Действие — **claim-before-act**: для exit0 канонический
|
||||
QG оценивается read-only ПЕРЕД атомарным claim, затем claim `done` ПЕРВЫМ и
|
||||
только победитель claim выполняет `_try_advance_stage` (advance+enqueue) —
|
||||
проигравший не делает побочных эффектов (источник истины — QG, не «exit0»);
|
||||
гейт красный или exit≠0 / неизвестно → `attempts<max` → `queued`, иначе
|
||||
`failed`+Telegram. Атомарный reap-claim (`UPDATE ... WHERE id=? AND
|
||||
status='running'` + `rowcount`, как `claim_next_job`) исключает двойную
|
||||
обработку (совместимость со стартовым `requeue_running_jobs`).
|
||||
2. **Проактивный реклейм stale/dead lease** — функции в `merge_gate.py`
|
||||
(`pid_alive`, `reclaim_stale_lease`), вызываемые на старте (рядом с
|
||||
`requeue_running_jobs`) и периодически из тика reaper. Освобождение, если
|
||||
держатель **мёртв** (pid не жив) ИЛИ **просрочен** (TTL); живой держатель в
|
||||
пределах TTL — НЕ трогать. holder-aware, never-raise, условность как ORCH-43.
|
||||
3. **Идемпотентная финализация merge** — без новой merge-логики: re-drive через
|
||||
reaper→`queued`→переисполнение стадии / reconciler; дорогие шаги не
|
||||
повторяются (`branch_is_behind_main==False`); добавлен детерминированный
|
||||
never-raise guard `pr_already_merged` (читает состояние PR), консультируемый
|
||||
перед повторным merge → уже слит = no-op.
|
||||
4. **Схема БД** — `jobs.pid INTEGER` через идемпотентный `_ensure_column`
|
||||
(паттерн live-safe миграции). Больше ничего не меняется.
|
||||
|
||||
Kill-switch'и (`ORCH_*`): `reaper_enabled`, `reaper_interval_s`,
|
||||
`reaper_dead_ticks`, `reaper_max_running_s`, `reaper_finalize_grace_s`,
|
||||
`lease_reclaim_enabled`; переиспользуются `merge_lock_timeout_s`,
|
||||
`merge_gate_repos`. `false` → строго прежнее поведение.
|
||||
|
||||
## Альтернативы
|
||||
- Reaper внутри reconciler — отвергнуто (смешение stage- и jobs-уровней, общий
|
||||
kill-switch, хуже изоляция).
|
||||
- Только эвристика `agent_runs` без `jobs.pid` — отвергнуто как основной механизм
|
||||
(не ловит зомби, чей monitor умер до записи exit_code); оставлена как Tier-2/3.
|
||||
- БД-lock / внешний брокер очередей — вне объёма (single-node SQLite).
|
||||
- Форс `done` по факту exit0 — отвергнуто; выбран gate-driven advance.
|
||||
|
||||
## Последствия
|
||||
- (+) Зомби-job и залипший lease самовосстанавливаются без рестарта и без
|
||||
оператора; очередь общего инстанса не встаёт; снят технический блокер ORCH-54.
|
||||
- (+) Контракты неизменны (`STAGE_TRANSITIONS`, `QG_CHECKS`, `check_*`, БАГ-8,
|
||||
exit-коды хука); одна колонка через проверенный idempotent-паттерн.
|
||||
- (−) pid-liveness валиден в предположении одного pid-namespace (агент —
|
||||
дочерний процесс оркестратора); закрыто backstop'ом по времени и TTL.
|
||||
- (−) streak-счётчик in-memory (сброс на рестарте; рестарт покрыт
|
||||
`requeue_running_jobs`).
|
||||
|
||||
## Связи
|
||||
- Базируется: adr-0002 (очередь), adr-0006 (merge-gate), adr-0007 (reconciler /
|
||||
self-deploy).
|
||||
- Разблокирует: ORCH-54.
|
||||
63
docs/architecture/adr/adr-0012-security-gate.md
Normal file
63
docs/architecture/adr/adr-0012-security-gate.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# adr-0012: Security-гейт — secret-scanning + dependency audit перед мержем
|
||||
|
||||
- **Статус:** proposed
|
||||
- **Дата:** 2026-06-07
|
||||
- **Задача:** ORCH-022
|
||||
- **Детальный ADR:** `docs/work-items/ORCH-022/06-adr/ADR-001-security-gate.md`
|
||||
|
||||
## Контекст
|
||||
Оркестратор автономен: `developer` пишет код без человека-фильтра. Перед слиянием ветки в
|
||||
`main` нет проверки на утёкший секрет (ключ/токен/пароль/приватный ключ) и уязвимую
|
||||
зависимость (CVE). Для self-hosting один общий прод-инстанс обслуживает все проекты с общей
|
||||
БД — секрет/CVE через одну задачу попадает в прод всех (CLAUDE.md §self-hosting, §8). Фактический
|
||||
мерж PR в `main` делает `deployer` в начале стадии `deploy`.
|
||||
|
||||
## Решение
|
||||
Детерминированный (без LLM) **security-гейт как под-гейт ребра `deploy-staging → deploy`**,
|
||||
рядом с merge-gate (ORCH-043) и image-freshness (ORCH-058), исполняемый **ПЕРВЫМ** среди
|
||||
edge-под-гейтов (ДО merge-gate). `STAGE_TRANSITIONS` не меняется; в `QG_CHECKS` добавлен
|
||||
`check_security_gate`. Паттерн — как у соседей: leaf-модуль `src/security_gate.py`
|
||||
(never-raise) + тонкая обёртка в `QG_CHECKS` + врезка `_handle_security_gate` в `advance_stage`.
|
||||
|
||||
- **Secret-scanning (`gitleaks`, offline):** скан `origin/main..HEAD`; любой секрет вне
|
||||
аллоулиста (`.gitleaks.toml`) → вклад в FAIL. Offline → гарантия «секрет всегда блокирует»
|
||||
не зависит от сети.
|
||||
- **Dependency audit (`pip-audit`, OSV/PyPI):** severity ≥ `security_dep_block_severity`
|
||||
(дефолт `HIGH`) → FAIL; ниже / UNKNOWN → warning. Недоступность фида → **fail-open +
|
||||
громкий warning** (анти-петля; флаг `security_dep_audit_fail_closed` для строгого режима).
|
||||
- **ПЕРВЫМ на ребре, ДО merge-gate:** дёшево фейлить до дорогих rebase/rebuild; скан ветки
|
||||
ДО rebase не «обвиняет» задачу в CVE, притащенной обновившимся `main` (анти-петля
|
||||
ORCH-061); до захвата merge-lease → при FAIL lease освобождать не нужно.
|
||||
- **Артефакт `17-security-report.md`** с YAML-frontmatter (`security_status`,
|
||||
`secrets_found`, `deps_blocking`, `deps_warning`, `deps_audit_degraded`); вердикт читается
|
||||
ТОЛЬКО из frontmatter (канон), negative-токен авторитетен; битый/нет → fail-closed.
|
||||
- **FAIL → откат на `development`** + developer-retry (общий `_developer_retry_count`, cap 3,
|
||||
затем `set_issue_blocked` + Telegram); `task_desc` несёт дословные находки (ORCH-046).
|
||||
- **Условность (как ORCH-35/43/58):** `security_gate_enabled` + `security_gate_repos`; пусто
|
||||
→ реально только self-hosting (`orchestrator`), прочие репо — no-op pass.
|
||||
- **never-raise**, таймаут `security_scan_timeout_s`, гейт не деплоит/не рестартит прод.
|
||||
|
||||
## Альтернативы
|
||||
- **Вариант R (review-стадия):** diff может разойтись с мержем в `main`; merge-edge — последняя
|
||||
страховка. Отклонено.
|
||||
- **Вариант C (CI-job через `check_ci_green`):** пороги/severity/аллоулист/артефакт плохо
|
||||
выражаются статусом коммита; коуплинг с раннером. Отклонено для v1 (точка расширения).
|
||||
- **Новая стадия `security`:** «пустая» стадия без агента не имеет триггера (как в ORCH-043).
|
||||
Отклонено.
|
||||
- **fail-closed dep-audit / аудит после rebase:** ложные откаты → петля. Отклонено.
|
||||
- **Новая колонка retry в БД:** не нужна (переиспользуем `_developer_retry_count`).
|
||||
|
||||
## Последствия
|
||||
- Класс «тихо влитый секрет/CVE» закрыт: секреты — безусловно (offline), CVE — best-effort при
|
||||
доступности фида. Самоприменение CLAUDE.md §8 без человека.
|
||||
- Плата: ещё один «скрытый» под-гейт ребра (нет в `STAGE_TRANSITIONS`); внешние инструменты
|
||||
(gitleaks в образе, pip-audit в зависимостях); время скана на каждом прогоне (ограничено
|
||||
таймаутом); v1 — Python-only (SAST/мульти-стек — follow-up WI).
|
||||
- Сквозное изменение (новый QG + edge-под-гейт) → `arch:major-change`; прод-деплой ORCH-022 —
|
||||
строго через staging-гейт (8501), без рестарта прод-контейнера.
|
||||
|
||||
## Связи
|
||||
adr-0006 (merge-gate — паттерн edge-под-гейта/отката), adr-0008 (image-freshness —
|
||||
условность/never-raise/fail-closed), adr-0003 (условный гейт / `is_self_hosting_repo`),
|
||||
adr-0009 (анти-петля ложных FAIL, ORCH-061), ORCH-046 (дословный reason в `task_desc`),
|
||||
ORCH-9/15 (мульти-стек — будущая зависимость), ORCH-2 (worktree-изоляция).
|
||||
@@ -326,6 +326,7 @@ webhook (plane/gitea) background thread (queue_worker)
|
||||
| `status` | `queued` → `running` → `done` \| `failed` |
|
||||
| `attempts` / `max_attempts` | счётчик попыток (инкремент при claim) / лимит ретраев (default 2) |
|
||||
| `run_id` | FK на `agent_runs.id` после старта |
|
||||
| `pid` | (ORCH-065) pid агентского процесса (`proc.pid` из `_spawn`); liveness-сигнал для job-reaper. Добавляется `_ensure_column` (idempotent) |
|
||||
| `task_content` | ТЗ, которое пишется в task-файл агента |
|
||||
| `error` | последняя ошибка |
|
||||
|
||||
@@ -343,6 +344,36 @@ status='queued'` и проверяет `rowcount`. При гонке двух т
|
||||
jobs со статусом `running` (воркер умёр на рестарте) → возвращаются в `queued`.
|
||||
Потом стартует воркер; на shutdown — `worker.stop()` (Event.set + join).
|
||||
|
||||
### Job-reaper (ORCH-065, рестарт НЕ требуется)
|
||||
|
||||
`requeue_running_jobs()` спасает ТОЛЬКО на старте процесса. Зомби-job, возникший
|
||||
**без** рестарта (умер monitor-поток/дочерний процесс, а сервис жив), оставался
|
||||
`running` навсегда и при `max_concurrency=1` блокировал всю очередь. Фоновый
|
||||
daemon-поток `src/job_reaper.py` (каркас `reconciler`) периодически
|
||||
(`reaper_interval_s`) сканирует `running`-jobs и реапит «мёртвые»:
|
||||
- **Tier-1** — `jobs.pid` мёртв (`os.kill(pid,0)`→`ProcessLookupError`) на
|
||||
протяжении `reaper_dead_ticks` подряд тиков (анти-ложноположительность);
|
||||
- **Tier-2** — у `agent_runs[run_id]` записан `exit_code`, а `jobs.status` ещё
|
||||
`running`. Окно неоднозначно: живой monitor пишет `exit_code` ПЕРВЫМ, затем
|
||||
git push/PR/Plane-комментарии (секунды-десятки секунд) и лишь потом
|
||||
`_finalize_job`; pid агента к этому моменту мёртв в обоих случаях. Поэтому
|
||||
Tier-2 реапит только после finalization-grace `reaper_finalize_grace_s`
|
||||
(`finished_age_s >= grace`) — живой финализирующий monitor НЕ реапится;
|
||||
- **Tier-3** — backstop: job висит `running` дольше `reaper_max_running_s`.
|
||||
|
||||
Реап атомарен (`UPDATE jobs SET ... WHERE id=? AND status='running'` + `rowcount`,
|
||||
как `claim_next_job`) → совместим со стартовым `requeue_running_jobs` без двойной
|
||||
обработки. Действие — **claim-before-act**: для exit0 канонический QG оценивается
|
||||
read-only ПЕРЕД атомарным claim, затем claim `done` ПЕРВЫМ и только победитель
|
||||
claim делает `_try_advance_stage` (advance+enqueue) — проигравший (поздний monitor
|
||||
/ стартовый requeue) не выполняет побочных эффектов (нет дубль-advance/-enqueue);
|
||||
источник истины — QG, не «exit0»; гейт красный или exit≠0/неизвестно →
|
||||
`attempts<max`→`queued`, иначе `failed`+Telegram. Тот же поток на старте и
|
||||
периодически делает проактивный реклейм stale/dead merge-lease (`merge_gate.py`:
|
||||
`pid_alive`/`reclaim_stale_lease`). never-raise; kill-switch `ORCH_REAPER_ENABLED`
|
||||
/ `ORCH_LEASE_RECLAIM_ENABLED`; снимок в `GET /queue` (блок `reaper`). Подробнее —
|
||||
adr-0011.
|
||||
|
||||
### Конфиг
|
||||
|
||||
- `ORCH_MAX_CONCURRENCY` (default 1) — лимит параллельных jobs.
|
||||
|
||||
78
docs/history/LESSONS_2026-06-07_autonomy-closure.md
Normal file
78
docs/history/LESSONS_2026-06-07_autonomy-closure.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# Lessons Learned — 2026-06-07: замыкание автономности self-deploy (5 задач в прод)
|
||||
|
||||
## Итог
|
||||
За одну сессию закрыты в прод **5 задач**, завершающих автономный self-deploy эпика ORCH-54:
|
||||
|
||||
| Задача | Что | Прод-коммит |
|
||||
|--------|-----|-------------|
|
||||
| ORCH-58 | provenance retag-guard (свежесть staging-образа перед BUILD-ONCE) | 094b5e2 |
|
||||
| ORCH-60 | reconciler не трогает escalated/Blocked/Needs-Input | d4c6cc0 |
|
||||
| ORCH-61 | фикс петли deploy-staging (staging_verdict: waive sandbox-infra FAILs C9a/C9b) | e18947d |
|
||||
| ORCH-21 | post-deploy мониторинг прода + auto-rollback (self-hosting=alert-only) | f85e449 |
|
||||
| ORCH-65 | job-reaper + stale merge-lease reclaim + idempotent merge | bb03350 |
|
||||
|
||||
**Главное:** после ORCH-60/61 конвейер впервые провёз задачи (ORCH-21/65) через deploy-staging
|
||||
**автономно** без отката; после ORCH-65 (job-reaper в проде) зомби-job и зависшие merge-lease
|
||||
лечатся сами. Последняя ручная точка автономного деплоя закрыта.
|
||||
|
||||
---
|
||||
|
||||
## Класс багов: «процесс умер — ресурс захвачен навсегда» (ORCH-65)
|
||||
Три связанных отказа, все воспроизвелись на ORCH-58/60/61/21:
|
||||
- **zombie jobs:** агент завершился/умер, строка jobs осталась running. requeue_running_jobs()
|
||||
спасает только на старте процесса; зомби без рестарта не лечился → при concurrency=1 встаёт
|
||||
конвейер ВСЕХ проектов. (jobs 236/239/242/254/265 — все зомби за сессию.)
|
||||
- **stale merge-lease:** merge-gate берёт .merge-lease-<repo>.json, делает rebase+re-test green,
|
||||
а на финальном merge процесс умирает с зажатым lease → merge не докатывается.
|
||||
- **неидемпотентный merge:** re-drive повторно пытается слить уже слитый PR.
|
||||
Фикс: фоновый job_reaper (паттерн reconciler, dead_ticks streak + мёртвый pid + exit_code,
|
||||
атомарный reap-claim, never-raise, kill-switch, снимок в /queue) + проактивный lease-reclaim
|
||||
по pid + guard pr_already_merged ПЕРЕД merge.
|
||||
|
||||
## Петля deploy-staging (ORCH-61) — ДВЕ причины
|
||||
1. ложный check_staging_status FAILED: staging_check падает на C9a/C9b (sandbox e2e branch +
|
||||
analyst-job-in-queue), т.к. bot-токены SANDBOX-проекта не настроены — НЕ регресс кода.
|
||||
2. no-changes для action-стадий (деплой = рестарт/retag, не правка → коммитить нечего).
|
||||
Фикс: staging_verdict waive sandbox-infra-only FAILs.
|
||||
|
||||
## Инфра-каскад от переполненного диска (инцидент дня)
|
||||
- Частые build-once/--build-staging пересборки за день забили docker build cache до 11 ГБ →
|
||||
диск 100% → CI red (No space left).
|
||||
- ДАЖЕ после чистки диска Gitea осталась в сломанном состоянии: внутренняя queue
|
||||
(/data/gitea/queues/common/*.log) залипла → post-receive hook 500 → actions tasks НЕ
|
||||
создаются, CI не триггерится вовсе (статус пустой, не failure). runner при этом online+idle.
|
||||
- Лечение: docker builder prune -af + рестарт Gitea (queue распускается → CI ожил).
|
||||
|
||||
---
|
||||
|
||||
## Уроки
|
||||
1. **Self-hosting safety (сквозной принцип):** прод-орк обслуживает ВСЕ проекты. Нельзя авто-
|
||||
откатывать/рестартить self в рамках задачи; нельзя пушить main. ORCH-21 post-deploy для
|
||||
self-hosting = alert-only, авто-rollback только для не-self репо.
|
||||
2. **TDD без доводки (повтор ORCH-58 и ORCH-65 v1):** тесты есть, реализация/wiring не
|
||||
подключены к боевому пути → мёртвый код + врущая дока. Reviewer обязан грепать вызовы из
|
||||
прод-кода, не только наличие функции.
|
||||
3. **Concurrency-баги ловятся итеративно:** ORCH-65 3 прохода reviewer (мёртвый guard → race
|
||||
condition side-effects-before-claim → approve) — каждый раз НОВЫЙ реальный дефект, не
|
||||
зацикливание. Atomic-claim ДО side-effects — обязательное правило.
|
||||
4. **При красном CI + зелёных локальных тестах — ПЕРВЫМ делом df -h / и docker system df**,
|
||||
не копаться в коде. После disk-full обязателен рестарт Gitea (queue залипает).
|
||||
5. **Bootstrap-разрыв:** задача про автономность деплоя не может задеплоить себя автономно,
|
||||
пока её механизм не в проде. Последний прод-деплой каждого такого фикса — вручную.
|
||||
6. **Перед прод-retag (build-once SOURCE_IMAGE=staging):** проверить revision-label staging-
|
||||
образа == целевой main HEAD, иначе guard fail-closed (by design). Если != → пересобрать
|
||||
--build-staging GIT_SHA=<main HEAD>.
|
||||
|
||||
## Ручная доводка прод-deploy (схема до ORCH-65 в проде)
|
||||
cancel zombie job → park task In Progress → merge PR (Gitea pulls/{n}/merge Do=merge, CI green)
|
||||
→ --build-staging GIT_SHA=<main HEAD> (проставит label) → rollback-снимок → --deploy с
|
||||
EXPECTED_REVISION=<sha> (guard сверит → retag → health 200) → Plane Done + UPDATE tasks stage=done.
|
||||
|
||||
## Follow-up (Backlog)
|
||||
- ORCH-62: авто-prune docker build cache (cron/daemon.json defaultKeepStorage).
|
||||
- ORCH-63: мониторинг диска mva154 + алерт >85%.
|
||||
- ORCH-64: починить NTP/часы mva154 (ушли ~+3ч от UTC).
|
||||
|
||||
## Осталось в эпике ORCH-54
|
||||
ORCH-22 (security-гейт), ORCH-59 (Confirm Deploy статус), ORCH-23 (budget circuit-breaker),
|
||||
P2: ORCH-57, ORCH-51.
|
||||
7
docs/work-items/ORCH-022/00-business-request.md
Normal file
7
docs/work-items/ORCH-022/00-business-request.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Business Request: [★ высокий] Security-гейт: secret-scanning + аудит зависимостей перед мержем
|
||||
|
||||
Work Item ID: ORCH-022
|
||||
|
||||
## Description
|
||||
|
||||
TBD
|
||||
150
docs/work-items/ORCH-022/01-brd.md
Normal file
150
docs/work-items/ORCH-022/01-brd.md
Normal file
@@ -0,0 +1,150 @@
|
||||
# 01 — BRD: Security-гейт (secret-scanning + аудит зависимостей перед мержем)
|
||||
|
||||
Work Item: **ORCH-022**
|
||||
Приоритет: **★ высокий**
|
||||
Источник: предложение Стрим, одобрено Славой (2026-06-04).
|
||||
Стадия: analysis.
|
||||
|
||||
---
|
||||
|
||||
## 1. Бизнес-проблема
|
||||
|
||||
Оркестратор — автономная мульти-агентная система: агенты (`developer`) пишут код
|
||||
**без человека-фильтра по умолчанию**. Перед мержем в `main` сейчас нет проверки на:
|
||||
|
||||
- **утёкший секрет** — закоммиченный API-ключ / токен / пароль / приватный ключ;
|
||||
- **дырявую зависимость** — пакет с известной CVE;
|
||||
- (опционально) **базовую уязвимость кода** — типовой SAST-паттерн.
|
||||
|
||||
Для автономной системы это критично: ошибку, которую в обычной команде «выловили бы
|
||||
глазами на ревью», здесь поймать некому. Утёкший в `git`-историю ключ или уязвимая
|
||||
зависимость может уехать в прод и обслуживать **все** проекты (общий инстанс,
|
||||
self-hosting).
|
||||
|
||||
### Прецеденты / связки
|
||||
- **PR #18** (`check_ci_green`: красный CI → возврат на `development`) — задаёт целевой
|
||||
паттерн поведения красного гейта. Security-гейт должен вести себя так же.
|
||||
- **Управление секретами** (CLAUDE.md §8): секреты живут только в `.env`/`.env.staging`
|
||||
на хосте, канон — `.env.example`. Гейт — это автоматический страж этого правила.
|
||||
|
||||
---
|
||||
|
||||
## 2. Цель
|
||||
|
||||
Ввести **security-гейт перед слиянием ветки задачи в `main`**, который детерминированно
|
||||
(без LLM) проверяет diff/ветку на секреты и уязвимые зависимости и **блокирует
|
||||
продвижение** при нарушении порогов: красный security-гейт → **возврат на `development`**
|
||||
(developer-retry, как красный CI / merge-gate), задача **не уезжает в прод**.
|
||||
|
||||
### Бизнес-ценность
|
||||
- Структурно невозможно «тихо» влить секрет или известную CVE в прод автономной системы.
|
||||
- Самоприменение правила CLAUDE.md §8 (секреты не в гит) без участия человека.
|
||||
- Расширяет уже выстроенную линию автономных страховок (CI-гейт, merge-gate ORCH-043,
|
||||
staging-провенанс ORCH-058, post-deploy ORCH-021).
|
||||
|
||||
---
|
||||
|
||||
## 3. Объём (Scope)
|
||||
|
||||
### 3.1 В объёме (v1) — **предположение по умолчанию (A1)**
|
||||
1. **Secret-scanning** — обязательный минимум гейта. Поиск закоммиченных секретов
|
||||
в ветке задачи / её diff относительно `main`.
|
||||
2. **Dependency audit** — аудит зависимостей проекта на известные CVE.
|
||||
3. **Машиночитаемый артефакт-вердикт** security-гейта (YAML-frontmatter — канон гейтов).
|
||||
4. **Поведение красного гейта** = откат на `development` + developer-retry (cap
|
||||
`MAX_DEVELOPER_RETRIES = 3`), наблюдаемость (Telegram + Plane-коммент).
|
||||
5. **Условный раскат** (kill-switch + scope репозиториев), **never-raise**,
|
||||
self-hosting (`orchestrator`) — первым.
|
||||
|
||||
### 3.2 Вне объёма (v1) — **предположение (A2), отдельные WI**
|
||||
- **SAST (semgrep)** — вынесен в follow-up WI: шумнее, требует policy-тюнинга правил;
|
||||
гейт проектируется с точкой расширения под него, но в v1 не включается.
|
||||
- **Полноценный мульти-стек** (JS/npm, Android) — см. A3 ниже; в v1 целевой стек —
|
||||
Python (сам оркестратор). Связь с ORCH-9/15 фиксируется как зависимость на будущее.
|
||||
- Ретроспективное сканирование уже существующей истории `main` (гейт смотрит вперёд —
|
||||
ветку перед мержем, не чистит прошлое).
|
||||
- Управление аллоулистом ложных срабатываний через UI/Plane (в v1 — файл в репозитории).
|
||||
|
||||
### 3.3 Зафиксированные предположения по умолчанию
|
||||
> ⚠️ Интерактивный опрос Owner на стадии анализа не дал ответа; ниже —
|
||||
> **дефолты по конвенциям проекта**. Любой из них Owner/архитектор может переопределить
|
||||
> (для A4 предусмотрены конфиг-флаги порогов).
|
||||
|
||||
- **A1 (объём сканеров v1):** secret-scanning + dependency-audit. SAST отложен.
|
||||
- **A2 (SAST):** отложен в отдельный WI; гейт оставляет точку расширения.
|
||||
- **A3 (стек):** **Python-only сначала**, реально только для self-hosting
|
||||
(`is_self_hosting_repo` / scope-CSV), как ORCH-35/43/58. Прочие репо — no-op pass.
|
||||
Мульти-стек (детект стека по репо) — отдельный WI.
|
||||
- **A4 (пороги):** **секреты — всегда блок**; **зависимости — блок на HIGH/CRITICAL,
|
||||
warning на MEDIUM/LOW**. Пороги вынесены в конфиг (переопределяемы без редеплоя кода).
|
||||
|
||||
---
|
||||
|
||||
## 4. Заинтересованные стороны
|
||||
| Роль | Интерес |
|
||||
|------|---------|
|
||||
| Owner (Слава) | Прод-безопасность автономного конвейера; контроль порогов и раската. |
|
||||
| Стрим | Инициатор; снижение риска утечки/уязвимости в автономном режиме. |
|
||||
| Агент `developer` | Получает понятную причину красного гейта → быстрый фикс. |
|
||||
| Агент `reviewer` | Гейт снимает с него непосильную задачу «глазами ловить ключи». |
|
||||
| Все проекты на инстансе | Общий прод не должен получить секрет/CVE через одну задачу. |
|
||||
|
||||
---
|
||||
|
||||
## 5. Бизнес-требования
|
||||
|
||||
| ID | Требование | Приоритет |
|
||||
|----|-----------|-----------|
|
||||
| BR-1 | Перед слиянием ветки задачи в `main` обязателен security-гейт (секреты + аудит зависимостей). | MUST |
|
||||
| BR-2 | Найден секрет (порог A4) → гейт **красный** → откат на `development`, в прод не уходит. | MUST |
|
||||
| BR-3 | Уязвимость зависимости уровня блокировки (порог A4) → гейт **красный** → откат на `development`. | MUST |
|
||||
| BR-4 | Уязвимость ниже порога блокировки → **warning**, продвижение не блокируется, но фиксируется в артефакте. | MUST |
|
||||
| BR-5 | Красный гейт ведёт себя как красный CI / merge-gate: откат на `development` + developer-retry (cap 3), затем эскалация (Telegram + Plane Blocked). | MUST |
|
||||
| BR-6 | Вердикт гейта — **машиночитаемый** (YAML-frontmatter артефакта), читается гейтом ТОЛЬКО из frontmatter (канон проекта), не из прозы. | MUST |
|
||||
| BR-7 | Гейт **детерминированный, без LLM** в критическом пути (как merge-gate / image-freshness). | MUST |
|
||||
| BR-8 | Гейт **never-raise**: внутренняя ошибка не роняет `advance_stage` и не вешает конвейер всех проектов. | MUST |
|
||||
| BR-9 | Условный раскат: глобальный kill-switch + scope-CSV репозиториев; пусто → реально только self-hosting (`orchestrator`), прочие репо — no-op pass. | MUST |
|
||||
| BR-10 | Пороги блокировки конфигурируемы (env-флаги, без редеплоя кода). | SHOULD |
|
||||
| BR-11 | Наблюдаемость: причина блокировки видна (Telegram + Plane-коммент + артефакт); проход — без шума. | MUST |
|
||||
| BR-12 | Документация (CLAUDE.md «Артефакты задачи», `docs/architecture/README.md` таблица гейтов, CHANGELOG, ADR) обновлена в том же PR. | MUST |
|
||||
| BR-13 | Аллоулист ложных срабатываний (заведомо-безопасные совпадения, напр. в `.env.example`, фикстуры тестов) поддерживается версионируемым файлом в репозитории. | SHOULD |
|
||||
| BR-14 | Точка расширения под SAST и мульти-стек заложена, но в v1 не активна (A2/A3). | SHOULD |
|
||||
|
||||
---
|
||||
|
||||
## 6. Ограничения и риски (бизнес-уровень)
|
||||
- **Self-hosting:** гейт исполняется внутри инстанса, который правит сам себя. Запрет на
|
||||
рестарт/падение прод-контейнера в рамках задачи (CLAUDE.md §self-hosting) сохраняется —
|
||||
гейт ничего не деплоит и не рестартит, только читает/сканирует.
|
||||
- **Ложные срабатывания** (false positives) могут зациклить откат `→ development`
|
||||
(прецедент ORCH-061 со staging-петлёй). Митигировано: cap retry=3 + аллоулист (BR-13)
|
||||
+ конфигурируемые пороги (BR-10) + kill-switch (BR-9).
|
||||
- **Внешние БД уязвимостей** (CVE-фиды) — сетевая зависимость; недоступность фида не
|
||||
должна давать ложный красный (см. AC: degrade-поведение при недоступности фида —
|
||||
решение порога «fail-open vs fail-closed для аудита» закрепляется в acceptance + ADR).
|
||||
- **Стоимость/время** сканирования добавляется к каждому прогону задачи — должно быть
|
||||
ограничено таймаутом (как merge-retest).
|
||||
|
||||
---
|
||||
|
||||
## 7. Критерий успеха (бизнес)
|
||||
Ветка с подсаженным тестовым секретом и/или зависимостью с известной CRITICAL-CVE
|
||||
**не может** дойти до `main`/прода: гейт краснеет, задача откатывается на `development`
|
||||
с понятной причиной. Чистая ветка проходит гейт без задержек и без шума. Для не-self
|
||||
репозиториев конвейер не меняется (no-op). Прод-контейнер не рестартится гейтом.
|
||||
|
||||
---
|
||||
|
||||
## 8. Открытые вопросы (для архитектора / Owner)
|
||||
1. **Размещение гейта** (решение архитектора): (а) на стадии `review`, либо (б) отдельный
|
||||
под-гейт перед мержем на ребре `deploy-staging → deploy` (где уже живёт merge-gate
|
||||
ORCH-043 / image-freshness ORCH-058). Требование BRD — «перед слиянием в `main`»;
|
||||
обе опции его удовлетворяют. См. 02-trz §4.
|
||||
2. **Где запускается сканер**: новый job в `.gitea/workflows/ci.yml` (тогда вердикт может
|
||||
течь через существующий `check_ci_green`) **или** отдельный QG-чек/под-гейт в `src/qg`.
|
||||
Решение — архитектор (02-trz фиксирует требования к обоим путям).
|
||||
3. **Аудит зависимостей при недоступном CVE-фиде:** fail-open (warning) или fail-closed
|
||||
(блок)? Дефолт-предложение — **fail-open с громким warning** (не плодить ложные
|
||||
завороты), закрепить в ADR.
|
||||
4. **Выбор конкретных инструментов** (gitleaks vs trufflehog; pip-audit vs trivy) —
|
||||
технологическое решение архитектора; BRD фиксирует только функцию.
|
||||
175
docs/work-items/ORCH-022/02-trz.md
Normal file
175
docs/work-items/ORCH-022/02-trz.md
Normal file
@@ -0,0 +1,175 @@
|
||||
# 02 — ТЗ: Security-гейт (secret-scanning + dependency audit)
|
||||
|
||||
Work Item: **ORCH-022** · Стадия: analysis · См. `01-brd.md`, `03-acceptance-criteria.md`.
|
||||
|
||||
> **Граница ответственности аналитика.** Ниже — *функциональные требования и точки
|
||||
> касания* кода. Выбор размещения гейта в пайплайне, конкретных инструментов и схемы
|
||||
> модулей — **решение архитектора** (см. §4 и `01-brd.md` §8). ТЗ фиксирует требования к
|
||||
> любому из допустимых вариантов и инварианты, которые нельзя нарушать.
|
||||
|
||||
---
|
||||
|
||||
## 1. Контекст кода (как есть)
|
||||
|
||||
- **Стадии:** `src/stages.py::STAGE_TRANSITIONS` — линейный конвейер
|
||||
`… review → testing → deploy-staging → deploy → done`. Фактический merge ветки в
|
||||
`main` делает агент `deployer` **в начале стадии `deploy`** (CLAUDE/README).
|
||||
- **Quality Gates:** `src/qg/checks.py` — реестр `QG_CHECKS` (имя → функция), сигнатуры
|
||||
диспетчеризуются в `src/stage_engine.py::_run_qg`.
|
||||
- **Существующий паттерн «красный гейт → возврат developer»:**
|
||||
`check_ci_green` (PR #18) и rollback-ветки в
|
||||
`stage_engine._handle_qg_failure_rollbacks` (откат на `development`, developer-retry,
|
||||
cap `MAX_DEVELOPER_RETRIES = 3`, затем `set_issue_blocked` + Telegram).
|
||||
- **Эталонный паттерн детерминированного под-гейта на ребре** (без LLM, never-raise,
|
||||
условный раскат, откат на `development`):
|
||||
- merge-gate **ORCH-043** — `src/merge_gate.py` + `check_branch_mergeable` +
|
||||
`stage_engine._handle_merge_gate` (ребро `deploy-staging → deploy`);
|
||||
- image-freshness **ORCH-058** — `src/image_freshness.py` + `_check_staging_image_fresh`
|
||||
+ `stage_engine._handle_image_freshness` (то же ребро).
|
||||
Оба: leaf-модуль с чистой логикой (never-raise) + тонкая обёртка в `QG_CHECKS` +
|
||||
врезка-обработчик в `advance_stage`, kill-switch `*_enabled` + scope `*_repos`,
|
||||
реально только для self-hosting при пустом scope.
|
||||
- **CI:** `.gitea/workflows/ci.yml` — один job `test` (pytest) на `self-hosted` раннере,
|
||||
push в `feature/**` и PR в `main`. `check_ci_green` читает комбинированный статус
|
||||
коммита из Gitea API.
|
||||
- **Артефакты задачи** нумерованы до `16-post-deploy-log.md`.
|
||||
- **Зависимости Python:** `requirements.txt` (корень репо).
|
||||
|
||||
---
|
||||
|
||||
## 2. Функциональные требования к реализации
|
||||
|
||||
### FR-1. Secret-scanning ветки перед мержем
|
||||
- Сканировать ветку задачи / её diff относительно `origin/main` на секреты
|
||||
(ключи, токены, пароли, приватные ключи).
|
||||
- **Любой** подтверждённый секрет (не из аллоулиста) → вердикт **FAIL** (порог A4: секреты
|
||||
всегда блокируют).
|
||||
- Инструмент (gitleaks / trufflehog) — выбор архитектора. Должен запускаться offline-/
|
||||
детерминированно (без LLM) и иметь конфиг правил/аллоулиста в репозитории.
|
||||
|
||||
### FR-2. Dependency audit
|
||||
- Аудит зависимостей целевого стека на известные CVE. Для Python — манифест
|
||||
`requirements.txt` (инструмент pip-audit / trivy — выбор архитектора).
|
||||
- Классификация по severity. **Порог блокировки (A4, конфигурируемо BR-10):**
|
||||
- `CRITICAL`, `HIGH` → вклад в **FAIL**;
|
||||
- `MEDIUM`, `LOW` → **warning** (фиксируется в артефакте, не блокирует).
|
||||
- Недоступность CVE-фида: degrade-поведение по решению ADR (дефолт-предложение —
|
||||
fail-open + громкий warning, чтобы не плодить ложные завороты). Поведение должно быть
|
||||
детерминированным и протестированным.
|
||||
|
||||
### FR-3. Машиночитаемый артефакт-вердикт
|
||||
- Гейт порождает артефакт security-отчёта с **YAML-frontmatter**, напр.:
|
||||
```
|
||||
---
|
||||
security_status: PASS # PASS | FAIL
|
||||
secrets_found: 0
|
||||
deps_blocking: 0 # число уязвимостей уровня блокировки
|
||||
deps_warning: 2
|
||||
---
|
||||
```
|
||||
Имя артефакта — предложение: **`17-security-report.md`** (следующий свободный номер;
|
||||
финализирует архитектор). Тело — человекочитаемый список находок.
|
||||
- Вердикт читается гейтом **ТОЛЬКО из frontmatter** (канон проекта: «машинные вердикты —
|
||||
строго YAML-frontmatter, никогда проза»), по образцу `_parse_deploy_status` /
|
||||
`_parse_staging_status` / `check_reviewer_verdict`. Negative-токен (FAIL) авторитетен.
|
||||
- Отсутствие/битый frontmatter → `(False, reason)` (fail-closed на чтении вердикта,
|
||||
как у существующих парсеров).
|
||||
|
||||
### FR-4. Поведение красного гейта (откат)
|
||||
- `security_status: FAIL` → откат на `development` + enqueue `developer`, по образцу
|
||||
`_handle_qg_failure_rollbacks` (merge-gate-ветка — точный шаблон):
|
||||
- cap `MAX_DEVELOPER_RETRIES` (3); при исчерпании — `set_issue_blocked` + Telegram-алерт;
|
||||
- `task_desc` для developer несёт **дословную причину** (какие секреты/CVE), по образцу
|
||||
ORCH-046 (встраивание must-fix в `task_desc`), а не только ссылку на артефакт;
|
||||
- Plane-коммент + `notify_qg_failure` (наблюдаемость BR-11).
|
||||
|
||||
### FR-5. Условный раскат (как ORCH-35/43/58)
|
||||
- Глобальный kill-switch `security_gate_enabled` (env `ORCH_SECURITY_GATE_ENABLED`,
|
||||
дефолт по согласованию; рекомендуется `true` с safety-net, как у соседних фич).
|
||||
- Scope `security_gate_repos` (CSV); пусто → реально только `is_self_hosting_repo(repo)`
|
||||
(`orchestrator`). Прочие репо → `(True, "security-gate N/A for <repo>")` (мгновенный pass).
|
||||
- Отдельные пороги-флаги (A4/BR-10): напр. `security_dep_block_severity`
|
||||
(`HIGH` по умолчанию), при желании `security_secrets_block` (`true`).
|
||||
|
||||
### FR-6. never-raise
|
||||
- Любая внутренняя ошибка гейта (сбой сканера, отсутствие бинаря, таймаут) →
|
||||
`(False, "<reason>")` **без** проброса исключения в `advance_stage`. Контракт —
|
||||
как у `check_branch_mergeable` (внешний + внутренний guard).
|
||||
- Таймаут сканирования ограничен (по образцу `merge_retest_timeout_s`).
|
||||
|
||||
### FR-7. Наблюдаемость
|
||||
- Блокировка → Telegram + Plane-коммент (BR-11). Проход → лог-строка, без шумных
|
||||
нотификаций (по образцу merge-gate pass).
|
||||
- Желательно: краткий снимок в `GET /queue` (опционально, по образцу блоков `reconcile`/
|
||||
`reaper`/`post_deploy`) — на усмотрение архитектора.
|
||||
|
||||
---
|
||||
|
||||
## 3. Задействованные модули `src/` (точки касания)
|
||||
|
||||
| Модуль | Изменение |
|
||||
|--------|-----------|
|
||||
| `src/security_gate.py` (**новый leaf-модуль**) | Чистая логика гейта: запуск сканеров, классификация по severity, применение порогов/аллоулиста, формирование вердикта + парсер frontmatter. **never-raise.** По образцу `src/merge_gate.py` / `src/image_freshness.py` / `src/post_deploy.py`. |
|
||||
| `src/qg/checks.py` | Новый чек `check_security_gate` (тонкая обёртка над `security_gate`, ленивый импорт во избежание циклов) + регистрация в `QG_CHECKS`. Условность (kill-switch/scope/self-hosting) — как `check_branch_mergeable` / `_check_staging_image_fresh`. |
|
||||
| `src/stage_engine.py` | Врезка-обработчик `_handle_security_gate(...)` по образцу `_handle_merge_gate` / `_handle_image_freshness`: вызов в `advance_stage` на выбранном архитектором ребре; FAIL → откат на `development` (FR-4); never-raise. **`STAGE_TRANSITIONS` НЕ меняется**, если выбран вариант «под-гейт ребра». |
|
||||
| `src/config.py` | Новые настройки: `security_gate_enabled`, `security_gate_repos`, `security_dep_block_severity`, `security_scan_timeout_s` (+ при необходимости пути к бинарям/конфигам сканеров). С docstring-комментариями по образцу ORCH-043/058. |
|
||||
| `.gitea/workflows/ci.yml` | **Если** архитектор выберет CI-путь: новый job `security` (secret-scan + dep-audit), влияющий на комбинированный статус коммита (тогда срабатывает `check_ci_green`-паттерн PR #18). Иначе — не трогается. |
|
||||
| `requirements.txt` / Dockerfile | Установка выбранных сканеров (если они Python-пакеты — в `requirements.txt`; если бинари — в Dockerfile/раннер). |
|
||||
| Конфиг сканера + аллоулист | Версионируемые файлы в репозитории (напр. `.gitleaks.toml` / аллоулист) — BR-13. |
|
||||
| `.openclaw/agents/developer.md` | (Если нужно) краткая инструкция developer'у про устранение security-находок при заворотах. |
|
||||
|
||||
> Если выбран вариант «гейт на стадии `review`» — врезка делается в соответствующую
|
||||
> ветку `advance_stage`/обработчик ревью вместо ребра `deploy-staging → deploy`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Размещение в пайплайне — варианты для архитектора
|
||||
|
||||
Требование BRD: **«перед слиянием ветки в `main`»**. Допустимы (выбор + обоснование — в ADR):
|
||||
|
||||
- **Вариант R (review):** security-проверка на стадии `review` (раньше отлов, дешевле
|
||||
откат — задача ещё близко к development). Минус: дальше по конвейеру `main` может уйти
|
||||
вперёд (но это закрывает merge-gate).
|
||||
- **Вариант M (merge-edge, рекомендуемый к рассмотрению):** под-гейт на ребре
|
||||
`deploy-staging → deploy`, рядом с merge-gate (ORCH-043) и image-freshness (ORCH-058) —
|
||||
непосредственно перед фактическим мержем `deployer`'ом. Плюс: единое место «последней
|
||||
страховки перед main», переиспользование готового паттерна врезки/отката/lease.
|
||||
- **Вариант C (CI-job):** добавить job в `ci.yml`; вердикт течёт через `check_ci_green`.
|
||||
Плюс: меньше нового кода в движке. Минус: пороги/severity-логика и артефакт-вердикт
|
||||
сложнее выразить только статусом коммита.
|
||||
|
||||
ТЗ не предписывает вариант; реализация обязана сохранить инварианты §6.
|
||||
|
||||
---
|
||||
|
||||
## 5. Изменения API
|
||||
- Новых HTTP-endpoint'ов **не требуется**.
|
||||
- Допустимо (опционально, FR-7): расширить ответ `GET /queue` блоком `security`
|
||||
(counts/last_run) — по образцу блоков `reconcile`/`reaper`/`post_deploy`. Не обязательно.
|
||||
|
||||
## 6. Изменения схемы БД
|
||||
- **Не требуется.** Состояние гейта — артефакт-файл + (при необходимости) sentinel-файлы,
|
||||
по образцу merge-lease / deploy-state / post-deploy-state. Миграций БД нет.
|
||||
- Если архитектор сочтёт нужным считать security-retry отдельно от developer-retry —
|
||||
предпочесть подсчёт по `jobs`/`agent_runs` (как `_developer_retry_count` /
|
||||
`_merge_defer_count`), без новых колонок.
|
||||
|
||||
## 7. Инварианты (НЕ нарушать)
|
||||
1. `STAGE_TRANSITIONS` и реестр `QG_CHECKS` остаются консистентными; при варианте
|
||||
«под-гейт ребра» — `STAGE_TRANSITIONS` не меняется (триггер — то же событие стадии).
|
||||
2. Машинный вердикт — только из YAML-frontmatter, не из прозы.
|
||||
3. never-raise: гейт никогда не пробрасывает исключение в `advance_stage`.
|
||||
4. Условность как ORCH-35/43/58: не-self репо при пустом scope не затрагиваются (no-op).
|
||||
5. Гейт **не деплоит и не рестартит** прод-контейнер (self-hosting safety).
|
||||
6. Откат и retry-счётчик developer не ломаются (cap=3, затем эскалация).
|
||||
7. Документация (CLAUDE.md, README, CHANGELOG, ADR) обновлена в том же PR (BR-12).
|
||||
|
||||
## 8. Артефакты pipeline, создаваемые/обновляемые
|
||||
- **Новый:** `docs/work-items/ORCH-022/17-security-report.md` (имя финализирует архитектор)
|
||||
с `security_status:`-frontmatter (FR-3) — порождается гейтом per-task.
|
||||
- **ADR:** `docs/work-items/ORCH-022/06-adr/ADR-001-<slug>.md` (решение: размещение,
|
||||
инструменты, degrade-поведение фида, пороги). При сквозном влиянии — global ADR в
|
||||
`docs/architecture/adr/`.
|
||||
- **Обновить:** `CLAUDE.md` (раздел «Артефакты задачи» — добавить 17-…),
|
||||
`docs/architecture/README.md` (таблица гейтов + реестр `QG_CHECKS` + новый раздел),
|
||||
`CHANGELOG.md`, `.env.example` (новые `ORCH_SECURITY_*`).
|
||||
140
docs/work-items/ORCH-022/03-acceptance-criteria.md
Normal file
140
docs/work-items/ORCH-022/03-acceptance-criteria.md
Normal file
@@ -0,0 +1,140 @@
|
||||
# 03 — Критерии приёмки: Security-гейт (ORCH-022)
|
||||
|
||||
Формат: каждый критерий имеет чёткое условие **PASS/FAIL**. Привязка к
|
||||
`01-brd.md` (BR-*) и `02-trz.md` (FR-*).
|
||||
|
||||
---
|
||||
|
||||
## A. Secret-scanning (FR-1, BR-1/BR-2)
|
||||
|
||||
### AC-1 — Подсаженный секрет блокирует гейт
|
||||
- **PASS:** ветка с тестовым секретом (напр. фиктивный AWS-ключ формата `AKIA…` вне
|
||||
аллоулиста) → `security_status: FAIL`; гейт возвращает `(False, reason)`, причина
|
||||
называет секрет/файл.
|
||||
- **FAIL:** секрет не обнаружен ИЛИ гейт зелёный при наличии секрета.
|
||||
|
||||
### AC-2 — Чистая ветка проходит
|
||||
- **PASS:** ветка без секретов → `security_status: PASS`; `secrets_found: 0`;
|
||||
гейт возвращает `(True, …)`.
|
||||
- **FAIL:** ложное срабатывание (FAIL на чистой ветке).
|
||||
|
||||
### AC-3 — Аллоулист подавляет заведомо-безопасное (BR-13)
|
||||
- **PASS:** совпадение, явно занесённое в версионируемый аллоулист (напр. плейсхолдер в
|
||||
`.env.example` / фикстура теста), **не** даёт FAIL.
|
||||
- **FAIL:** аллоулист игнорируется и даёт ложный FAIL.
|
||||
|
||||
---
|
||||
|
||||
## B. Dependency audit (FR-2, BR-3/BR-4)
|
||||
|
||||
### AC-4 — CVE уровня блокировки краснит гейт
|
||||
- **PASS:** зависимость с известной `CRITICAL`/`HIGH` CVE (при пороге по умолчанию
|
||||
`HIGH`) → вклад в `security_status: FAIL`; `deps_blocking >= 1`.
|
||||
- **FAIL:** блокирующая уязвимость не приводит к FAIL.
|
||||
|
||||
### AC-5 — Низкая severity = warning, не блок
|
||||
- **PASS:** только `MEDIUM`/`LOW` уязвимости → `security_status: PASS`, при этом
|
||||
`deps_warning >= 1` и находки перечислены в теле артефакта.
|
||||
- **FAIL:** `MEDIUM`/`LOW` блокирует продвижение.
|
||||
|
||||
### AC-6 — Порог блокировки конфигурируем (BR-10)
|
||||
- **PASS:** при `ORCH_SECURITY_DEP_BLOCK_SEVERITY=CRITICAL` та же `HIGH`-уязвимость
|
||||
становится warning (не блок); при `=HIGH` — блок. Поведение детерминированно
|
||||
определяется флагом.
|
||||
- **FAIL:** флаг не влияет на классификацию.
|
||||
|
||||
### AC-7 — Degrade при недоступном CVE-фиде
|
||||
- **PASS:** недоступность фида обрабатывается по решению ADR детерминированно и
|
||||
протестированно (дефолт: fail-open + громкий warning, гейт не краснеет ложно).
|
||||
- **FAIL:** недоступность фида даёт неконтролируемый красный/исключение.
|
||||
|
||||
---
|
||||
|
||||
## C. Вердикт и артефакт (FR-3, BR-6)
|
||||
|
||||
### AC-8 — Машинный вердикт только из frontmatter
|
||||
- **PASS:** вердикт читается ТОЛЬКО из YAML-frontmatter `17-security-report.md`; проза с
|
||||
«PASS»/«FAIL» в теле не влияет на решение. Negative-токен (FAIL) авторитетен.
|
||||
- **FAIL:** вердикт извлекается из тела/прозы.
|
||||
|
||||
### AC-9 — Битый/отсутствующий frontmatter → fail-closed на чтении
|
||||
- **PASS:** нет frontmatter / битый YAML / нет поля `security_status` → `(False, reason)`
|
||||
(как `_parse_deploy_status`/`check_reviewer_verdict`).
|
||||
- **FAIL:** битый артефакт трактуется как PASS.
|
||||
|
||||
### AC-10 — Артефакт создаётся с корректными полями
|
||||
- **PASS:** после прогона существует `17-security-report.md` с валидным frontmatter
|
||||
(`security_status`, `secrets_found`, `deps_blocking`, `deps_warning`) и телом-списком.
|
||||
- **FAIL:** артефакт не создан/без машинных полей.
|
||||
|
||||
---
|
||||
|
||||
## D. Откат и retry (FR-4, BR-5)
|
||||
|
||||
### AC-11 — Красный гейт → откат на development + developer-retry
|
||||
- **PASS:** `FAIL` → стадия задачи становится `development`, enqueue `developer`,
|
||||
Plane-коммент + `notify_qg_failure`; счётчик developer-retry растёт.
|
||||
- **FAIL:** при FAIL задача продвигается дальше / не откатывается.
|
||||
|
||||
### AC-12 — task_desc несёт дословную причину (ORCH-046-паттерн)
|
||||
- **PASS:** `task_desc` для перезапущенного developer содержит конкретику находок
|
||||
(какие секреты/CVE), а не только ссылку на артефакт.
|
||||
- **FAIL:** developer получает только ссылку без сути.
|
||||
|
||||
### AC-13 — Cap retry и эскалация
|
||||
- **PASS:** после `MAX_DEVELOPER_RETRIES` (3) безуспешных фиксов — `set_issue_blocked` +
|
||||
Telegram-алерт; бесконечного отскока нет.
|
||||
- **FAIL:** откат зацикливается без cap/эскалации.
|
||||
|
||||
---
|
||||
|
||||
## E. Условный раскат и устойчивость (FR-5/FR-6, BR-8/BR-9)
|
||||
|
||||
### AC-14 — Не-self репозиторий = no-op pass
|
||||
- **PASS:** для repo, не входящего в scope и не self-hosting → гейт возвращает
|
||||
`(True, "security-gate N/A for <repo>")` мгновенно, конвейер такого репо не меняется.
|
||||
- **FAIL:** гейт реально запускается/блокирует чужой репо при пустом scope.
|
||||
|
||||
### AC-15 — Kill-switch отключает гейт
|
||||
- **PASS:** `ORCH_SECURITY_GATE_ENABLED=false` → гейт — no-op pass (`(True, …)`),
|
||||
поведение конвейера 1:1 как до ORCH-022.
|
||||
- **FAIL:** при выключенном флаге гейт всё ещё блокирует.
|
||||
|
||||
### AC-16 — never-raise
|
||||
- **PASS:** искусственный сбой (нет бинаря сканера / таймаут / исключение внутри) →
|
||||
`(False, reason)` без проброса исключения; `advance_stage` не падает, конвейер других
|
||||
задач/проектов не встаёт.
|
||||
- **FAIL:** внутренняя ошибка пробрасывается/вешает движок.
|
||||
|
||||
### AC-17 — Таймаут ограничен
|
||||
- **PASS:** сканирование, превысившее `ORCH_SECURITY_SCAN_TIMEOUT_S`, корректно
|
||||
прерывается → детерминированный вердикт (по политике degrade), без зависания.
|
||||
- **FAIL:** сканер висит без таймаута.
|
||||
|
||||
---
|
||||
|
||||
## F. Инварианты и интеграция (BR-7/BR-12, TRZ §7)
|
||||
|
||||
### AC-18 — STAGE_TRANSITIONS/QG_CHECKS консистентны
|
||||
- **PASS:** при варианте «под-гейт ребра» `STAGE_TRANSITIONS` не изменён; новый чек
|
||||
зарегистрирован в `QG_CHECKS`; `_run_qg` корректно его диспетчеризует. Все
|
||||
существующие тесты гейтов/стадий зелёные.
|
||||
- **FAIL:** сломан реестр/переходы/существующие тесты.
|
||||
|
||||
### AC-19 — Гейт не деплоит/не рестартит прод
|
||||
- **PASS:** код гейта не вызывает деплой-хук/рестарт прод-контейнера; только
|
||||
чтение/сканирование.
|
||||
- **FAIL:** гейт инициирует рестарт/деплой.
|
||||
|
||||
### AC-20 — Документация обновлена в том же PR (BR-12)
|
||||
- **PASS:** обновлены `CLAUDE.md` (артефакт 17-…), `docs/architecture/README.md`
|
||||
(таблица гейтов + реестр QG + раздел ORCH-022), `CHANGELOG.md`, `.env.example`
|
||||
(`ORCH_SECURITY_*`); заведён ADR `06-adr/ADR-001-*`.
|
||||
- **FAIL:** функционал есть, документация/ADR не обновлены → reviewer обязан
|
||||
REQUEST_CHANGES (CLAUDE.md §6).
|
||||
|
||||
### AC-21 — End-to-end на тестовой задаче
|
||||
- **PASS:** прогон на self-hosting-репо: грязная ветка (секрет/CVE) → откат на
|
||||
`development`; после фикса чистая ветка → гейт зелёный → конвейер идёт дальше; прод не
|
||||
затронут в процессе.
|
||||
- **FAIL:** любой шаг E2E не воспроизводится.
|
||||
126
docs/work-items/ORCH-022/04-test-plan.yaml
Normal file
126
docs/work-items/ORCH-022/04-test-plan.yaml
Normal file
@@ -0,0 +1,126 @@
|
||||
work_item: ORCH-022
|
||||
title: "Security-гейт: secret-scanning + dependency audit перед мержем"
|
||||
notes: >
|
||||
План тестов для security-гейта. Чистая логика выносится в leaf-модуль
|
||||
src/security_gate.py (never-raise) — основной предмет unit-тестов (по образцу
|
||||
tests для merge_gate / image_freshness / post_deploy / staging_verdict).
|
||||
Интеграция врезки в advance_stage и условный раскат — integration-тесты.
|
||||
Имена модулей тестов финализирует разработчик/архитектор по факту реализации.
|
||||
|
||||
tests:
|
||||
# --- Secret-scanning (FR-1 / AC-1..AC-3) ---
|
||||
- id: TC-01
|
||||
type: unit
|
||||
description: "Подсаженный тестовый секрет в diff -> вердикт FAIL, secrets_found>=1, причина называет находку."
|
||||
module: tests/test_security_gate.py
|
||||
expected: PASS
|
||||
- id: TC-02
|
||||
type: unit
|
||||
description: "Чистая ветка без секретов -> вердикт PASS, secrets_found=0."
|
||||
module: tests/test_security_gate.py
|
||||
expected: PASS
|
||||
- id: TC-03
|
||||
type: unit
|
||||
description: "Совпадение из аллоулиста (плейсхолдер .env.example / фикстура) НЕ даёт FAIL."
|
||||
module: tests/test_security_gate.py
|
||||
expected: PASS
|
||||
|
||||
# --- Dependency audit + пороги (FR-2 / AC-4..AC-7) ---
|
||||
- id: TC-04
|
||||
type: unit
|
||||
description: "CVE уровня HIGH/CRITICAL при пороге HIGH -> вклад в FAIL, deps_blocking>=1."
|
||||
module: tests/test_security_gate.py
|
||||
expected: PASS
|
||||
- id: TC-05
|
||||
type: unit
|
||||
description: "Только MEDIUM/LOW уязвимости -> PASS, deps_warning>=1, находки в теле артефакта."
|
||||
module: tests/test_security_gate.py
|
||||
expected: PASS
|
||||
- id: TC-06
|
||||
type: unit
|
||||
description: "Конфиг порога: severity=CRITICAL делает HIGH-CVE warning; severity=HIGH делает её блоком."
|
||||
module: tests/test_security_gate.py
|
||||
expected: PASS
|
||||
- id: TC-07
|
||||
type: unit
|
||||
description: "Недоступный CVE-фид -> детерминированный degrade по политике ADR (дефолт fail-open + warning), без исключения и без ложного FAIL."
|
||||
module: tests/test_security_gate.py
|
||||
expected: PASS
|
||||
|
||||
# --- Вердикт / парсер frontmatter (FR-3 / AC-8..AC-10) ---
|
||||
- id: TC-08
|
||||
type: unit
|
||||
description: "Вердикт читается ТОЛЬКО из YAML-frontmatter; проза PASS/FAIL в теле не влияет; negative-токен авторитетен."
|
||||
module: tests/test_security_gate.py
|
||||
expected: PASS
|
||||
- id: TC-09
|
||||
type: unit
|
||||
description: "Нет frontmatter / битый YAML / нет поля security_status -> (False, reason) (fail-closed на чтении)."
|
||||
module: tests/test_security_gate.py
|
||||
expected: PASS
|
||||
- id: TC-10
|
||||
type: unit
|
||||
description: "Артефакт 17-security-report.md создаётся с валидным frontmatter (security_status, secrets_found, deps_blocking, deps_warning) и телом-списком."
|
||||
module: tests/test_security_gate.py
|
||||
expected: PASS
|
||||
|
||||
# --- never-raise / таймаут / условность (FR-5/FR-6 / AC-14..AC-17) ---
|
||||
- id: TC-11
|
||||
type: unit
|
||||
description: "Отсутствие бинаря сканера / внутреннее исключение -> (False, reason), исключение не пробрасывается (never-raise)."
|
||||
module: tests/test_security_gate.py
|
||||
expected: PASS
|
||||
- id: TC-12
|
||||
type: unit
|
||||
description: "Превышение ORCH_SECURITY_SCAN_TIMEOUT_S -> корректное прерывание и детерминированный вердикт, без зависания."
|
||||
module: tests/test_security_gate.py
|
||||
expected: PASS
|
||||
- id: TC-13
|
||||
type: unit
|
||||
description: "check_security_gate: не-self репо при пустом scope -> (True, 'security-gate N/A for <repo>') мгновенно."
|
||||
module: tests/test_qg_security.py
|
||||
expected: PASS
|
||||
- id: TC-14
|
||||
type: unit
|
||||
description: "check_security_gate: ORCH_SECURITY_GATE_ENABLED=false -> no-op pass (True)."
|
||||
module: tests/test_qg_security.py
|
||||
expected: PASS
|
||||
- id: TC-15
|
||||
type: unit
|
||||
description: "Новый чек зарегистрирован в QG_CHECKS и корректно диспетчеризуется _run_qg."
|
||||
module: tests/test_qg_security.py
|
||||
expected: PASS
|
||||
|
||||
# --- Откат / retry в stage_engine (FR-4 / AC-11..AC-13) ---
|
||||
- id: TC-16
|
||||
type: integration
|
||||
description: "security_status FAIL -> advance_stage откатывает на development, enqueue developer, Plane-коммент + notify_qg_failure."
|
||||
module: tests/test_stage_engine_security_gate.py
|
||||
expected: PASS
|
||||
- id: TC-17
|
||||
type: integration
|
||||
description: "task_desc перезапущенного developer содержит дословную причину находок (ORCH-046-паттерн), не только ссылку."
|
||||
module: tests/test_stage_engine_security_gate.py
|
||||
expected: PASS
|
||||
- id: TC-18
|
||||
type: integration
|
||||
description: "После MAX_DEVELOPER_RETRIES (3) -> set_issue_blocked + Telegram-алерт; бесконечного отскока нет."
|
||||
module: tests/test_stage_engine_security_gate.py
|
||||
expected: PASS
|
||||
- id: TC-19
|
||||
type: integration
|
||||
description: "security_status PASS -> advance_stage продвигает конвейер штатно (без отката, без шумных нотификаций)."
|
||||
module: tests/test_stage_engine_security_gate.py
|
||||
expected: PASS
|
||||
|
||||
# --- Инварианты / интеграция (BR-7/BR-12 / AC-18..AC-19) ---
|
||||
- id: TC-20
|
||||
type: integration
|
||||
description: "При варианте 'под-гейт ребра' STAGE_TRANSITIONS не изменён; существующие тесты стадий/гейтов остаются зелёными."
|
||||
module: tests/test_stages.py
|
||||
expected: PASS
|
||||
- id: TC-21
|
||||
type: integration
|
||||
description: "Гейт не вызывает деплой-хук/рестарт прод-контейнера (self-hosting safety)."
|
||||
module: tests/test_stage_engine_security_gate.py
|
||||
expected: PASS
|
||||
235
docs/work-items/ORCH-022/06-adr/ADR-001-security-gate.md
Normal file
235
docs/work-items/ORCH-022/06-adr/ADR-001-security-gate.md
Normal file
@@ -0,0 +1,235 @@
|
||||
# ADR-001: Security-гейт — secret-scanning + dependency audit перед мержем
|
||||
|
||||
- **Статус:** Accepted (proposed → принято архитектором ORCH-022)
|
||||
- **Дата:** 2026-06-07
|
||||
- **Задача:** ORCH-022
|
||||
- **Связанный global ADR:** `docs/architecture/adr/adr-0012-security-gate.md`
|
||||
- **Источники:** `01-brd.md` (BR-1..BR-14), `02-trz.md` (FR-1..FR-7, §4 варианты, §7 инварианты),
|
||||
`03-acceptance-criteria.md` (AC-1..AC-21).
|
||||
|
||||
---
|
||||
|
||||
## Контекст
|
||||
|
||||
Оркестратор автономен: `developer`-агент пишет код без человека-фильтра. Перед слиянием
|
||||
ветки задачи в `main` нет автоматической проверки на утёкший секрет (ключ/токен/пароль/
|
||||
приватный ключ) и на уязвимую зависимость (известная CVE). Для self-hosting это особенно
|
||||
опасно: один общий прод-инстанс обслуживает все проекты с общей БД — секрет или CVE,
|
||||
просочившийся через одну задачу, попадает в прод всех проектов (CLAUDE.md §self-hosting, §8).
|
||||
|
||||
Конвейер уже содержит линию детерминированных страховок на ребре `deploy-staging → deploy`
|
||||
(непосредственно перед фактическим мержем PR в `main`, который делает `deployer` в начале
|
||||
стадии `deploy`):
|
||||
|
||||
- **merge-gate** (ORCH-043, `check_branch_mergeable`) — догон `main` + re-test + сериализация;
|
||||
- **image-freshness** (ORCH-058, `check_staging_image_fresh`) — провенанс staging-образа.
|
||||
|
||||
Оба построены по одному паттерну: **leaf-модуль чистой логики (never-raise) + тонкая обёртка
|
||||
в `QG_CHECKS` + врезка-обработчик `_handle_*` в `advance_stage`**, с условным раскатом
|
||||
(`*_enabled` + `*_repos`, реально только для self-hosting при пустом scope) и откатом на
|
||||
`development` с developer-retry (cap `MAX_DEVELOPER_RETRIES = 3`).
|
||||
|
||||
Открытые вопросы BRD §8 / TRZ §4, требующие решения архитектора:
|
||||
1. Размещение гейта в пайплайне (review / merge-edge / CI-job).
|
||||
2. Где запускается сканер (CI-job через `check_ci_green` / отдельный QG-чек).
|
||||
3. Degrade при недоступном CVE-фиде (fail-open / fail-closed).
|
||||
4. Выбор инструментов (gitleaks/trufflehog; pip-audit/trivy).
|
||||
|
||||
---
|
||||
|
||||
## Решение
|
||||
|
||||
### Р-1. Размещение — Вариант M (под-гейт ребра `deploy-staging → deploy`), ПЕРВЫМ среди edge-под-гейтов
|
||||
|
||||
Security-гейт реализуется как **детерминированный под-гейт того же ребра**
|
||||
`deploy-staging → deploy`, что merge-gate и image-freshness, и исполняется **ПЕРВЫМ** —
|
||||
**ДО** merge-gate. `STAGE_TRANSITIONS` **не меняется** (триггер — то же событие «staging-
|
||||
deployer завершился»; инвариант TRZ §7.1).
|
||||
|
||||
Порядок врезок в `advance_stage` (блок `current_stage == "deploy-staging"`):
|
||||
|
||||
```
|
||||
check_staging_status (PASS, существующий QG стадии)
|
||||
→ security-gate (НОВЫЙ, _handle_security_gate) ← первым
|
||||
→ merge-gate (_handle_merge_gate)
|
||||
→ image-freshness (_handle_image_freshness)
|
||||
→ Phase A (self-deploy approve)
|
||||
```
|
||||
|
||||
**Почему merge-edge, а не review (Вариант R):**
|
||||
- BRD-требование «перед слиянием в `main`» удовлетворяют оба, но на review-стадии diff
|
||||
может разойтись с тем, что реально вольётся в `main` (параллельная задача двигает `main`
|
||||
вперёд между review и merge). Merge-edge — последняя точка перед фактическим мержем.
|
||||
- Переиспользуется готовая машинерия отката/retry/нотификаций edge-под-гейтов
|
||||
(минимальный blast-radius, инвариант TRZ §7).
|
||||
|
||||
**Почему ПЕРВЫМ (до merge-gate), а не после image-freshness:**
|
||||
- **Дёшево фейлить.** merge-gate (rebase + re-test, минуты) и image-freshness (docker
|
||||
rebuild, до 1200с) — дорогие. Нет смысла гонять их на ветке с секретом/CVE.
|
||||
- **Корректность для секретов.** Секрет живёт в собственных коммитах ветки;
|
||||
rebase онто `main` его не добавляет и не убирает → скан диапазона `origin/main..HEAD`
|
||||
до rebase ловит ровно те коммиты, что попадут в `main`.
|
||||
- **Анти-петля для зависимостей.** Аудит ветки **до** rebase оценивает то, что вносит
|
||||
ИМЕННО эта задача (её `requirements.txt`/diff), а не уязвимость, которую притащил в
|
||||
ветку обновившийся `main`. Аудит после rebase «обвинял» бы задачу в чужой (main'овой)
|
||||
CVE → ложный откат `→ development` → петля (прецедент ORCH-061). Скан до rebase этого
|
||||
избегает.
|
||||
- **Проще, чем image-freshness.** Гейт исполняется ДО захвата merge-lease → при FAIL
|
||||
**lease освобождать не нужно** (в отличие от `_handle_image_freshness`). Чистый откат.
|
||||
|
||||
**Почему не CI-job (Вариант C):** пороги severity, warning-vs-block, аллоулист и
|
||||
машиночитаемый артефакт-вердикт плохо выражаются одним статусом коммита Gitea; путь
|
||||
коуплится с CI-раннером. Отклонено для v1; оставлено как точка расширения (BR-14).
|
||||
|
||||
### Р-2. Инструменты
|
||||
|
||||
- **Secret-scanning — `gitleaks`.** Полностью **offline** (без сетевого фида → гарантия
|
||||
«секрет всегда блокирует» не зависит от сети, BR-2), один статический бинарь,
|
||||
детерминированный, конфиг + аллоулист в репо (`.gitleaks.toml`, BR-13), поддержка
|
||||
`--log-opts="origin/main..HEAD"` (скан диапазона), JSON-отчёт, exit-code контракт
|
||||
(0 = чисто, 1 = найдены секреты, ≥2 = ошибка инструмента). Бинарь устанавливается в
|
||||
`Dockerfile` (Go-бинарь, не pip-пакет) — см. `07-infra-requirements.md`.
|
||||
- **Dependency audit — `pip-audit`.** Python-native (v1-стек — сам оркестратор, Python),
|
||||
читает `requirements.txt`, источник advisory — OSV/PyPI, JSON-выход, ставится через
|
||||
`requirements.txt`. trivy/trufflehog отклонены как тяжелее/контейнер-ориентированные для
|
||||
v1-цели «Python-only» (A3).
|
||||
|
||||
Конкретные инструменты — деталь реализации; контракт гейта (вход: repo/branch/wi,
|
||||
выход: `(bool, reason)` + артефакт) от них не зависит, заменяемы за leaf-модулем.
|
||||
|
||||
### Р-3. Degrade при недоступном CVE-фиде — **fail-open + громкий warning** (дефолт)
|
||||
|
||||
`pip-audit` требует сети (OSV/PyPI advisory DB). Недоступность фида **по умолчанию**:
|
||||
- **fail-open**: dep-audit не даёт FAIL по причине недоступности фида (иначе — ложные
|
||||
откаты `→ development` → петля при сетевых проблемах прод-инстанса, прецедент ORCH-061);
|
||||
- **громко**: в артефакте `deps_audit_degraded: true`, лог `logger.warning`, Telegram-алерт.
|
||||
- **Секреты не деградируют:** gitleaks offline → гарантия BR-2 безусловна даже при
|
||||
отсутствии сети. Деградирует ТОЛЬКО dep-audit.
|
||||
- **Конфигурируемо:** флаг `security_dep_audit_fail_closed` (дефолт `false`) позволяет
|
||||
Owner'у переключить на fail-closed (недоступность фида → FAIL) без редеплоя кода.
|
||||
|
||||
Это разделяет две гарантии: «нет секрета в прод» — **безусловная**; «нет известной CVE» —
|
||||
**best-effort при доступности фида**. Закреплено в acceptance (AC-7).
|
||||
|
||||
### Р-4. Пороги классификации (A4, BR-10)
|
||||
|
||||
- **Секреты:** любой подтверждённый (не из аллоулиста) секрет → **вклад в FAIL** (всегда
|
||||
блок; флаг `security_secrets_block`, дефолт `true`).
|
||||
- **Зависимости:** severity ≥ `security_dep_block_severity` (дефолт `HIGH`) → **вклад в
|
||||
FAIL** (`deps_blocking`); ниже порога (`MEDIUM`/`LOW`) → **warning** (`deps_warning`,
|
||||
не блокирует, фиксируется в теле).
|
||||
- **Severity = UNKNOWN** (OSV/advisory без CVSS — частый случай pip-audit): трактуется как
|
||||
**ниже порога → warning**, никогда не авто-блок (анти-петля). Логируется.
|
||||
|
||||
### Р-5. Артефакт и вердикт (FR-3, BR-6, канон проекта)
|
||||
|
||||
- Новый артефакт **`17-security-report.md`** (следующий свободный номер; финализировано).
|
||||
- YAML-frontmatter:
|
||||
```
|
||||
---
|
||||
security_status: PASS # PASS | FAIL
|
||||
secrets_found: 0
|
||||
deps_blocking: 0
|
||||
deps_warning: 2
|
||||
deps_audit_degraded: false
|
||||
---
|
||||
```
|
||||
Тело — человекочитаемый список находок (секреты: файл/правило/маскированное совпадение;
|
||||
CVE: пакет/версия/идентификатор/severity).
|
||||
- **Единый источник истины:** гейт вычисляет находки → пишет артефакт → **читает вердикт
|
||||
обратно через `parse_security_status(content)`** (frontmatter-парсер по образцу
|
||||
`_parse_deploy_status`/`_parse_staging_status`) → возвращает этот вердикт. Так возвращаемый
|
||||
`(bool, reason)` гарантированно == frontmatter артефакта (канон «машинный вердикт — только
|
||||
из YAML-frontmatter, никогда из прозы», AC-8). Negative-токен (`FAIL`) авторитетен.
|
||||
- Битый/отсутствующий frontmatter / нет поля `security_status` → `(False, reason)` —
|
||||
fail-closed на чтении вердикта (AC-9).
|
||||
|
||||
### Р-6. Поведение красного гейта (FR-4, BR-5)
|
||||
|
||||
`security_status: FAIL` → врезка `_handle_security_gate` (по образцу
|
||||
`_handle_image_freshness`, но БЕЗ работы с lease — гейт до его захвата):
|
||||
- `update_task_stage(development)` + `enqueue_job("developer", …)`;
|
||||
- retry-счётчик — **существующий** `_developer_retry_count` (общий с merge/freshness;
|
||||
без новой колонки, TRZ §6); cap `MAX_DEVELOPER_RETRIES = 3` → при исчерпании
|
||||
`set_issue_blocked` + Telegram;
|
||||
- `task_desc` несёт **дословную причину** (какие секреты/файлы, какие пакеты/CVE/severity)
|
||||
по образцу ORCH-046 — не только ссылку на артефакт (AC-12);
|
||||
- `notify_qg_failure` + Plane-коммент (наблюдаемость BR-11).
|
||||
|
||||
PASS → `return False` из обработчика → `advance_stage` идёт к merge-gate (тишина, без шума).
|
||||
|
||||
### Р-7. Условный раскат и устойчивость (FR-5/FR-6)
|
||||
|
||||
- `check_security_gate(repo, work_item_id, branch)` в `QG_CHECKS`; обёртка делегирует в
|
||||
`src/security_gate.py` (ленивый импорт во избежание цикла — по образцу
|
||||
`_check_staging_image_fresh`).
|
||||
- Условность: `security_gate_enabled=False` → `(True, "security-gate disabled")`;
|
||||
`security_gate_repos` (CSV) пусто → реально только `is_self_hosting_repo` → прочие репо
|
||||
`(True, "security-gate N/A for <repo>")` (AC-14/AC-15).
|
||||
- **never-raise** (двойной guard как `check_branch_mergeable`): любая ошибка (нет бинаря,
|
||||
таймаут, исключение) → `(False, reason)`, исключение не уходит в `advance_stage` (AC-16).
|
||||
- Таймаут сканирования `security_scan_timeout_s` (дефолт 300) на каждый внешний вызов
|
||||
(`subprocess … timeout=`) — превышение → детерминированный degrade-вердикт (AC-17).
|
||||
|
||||
### Р-8. Self-hosting safety (инвариант TRZ §7.5, AC-19)
|
||||
|
||||
Гейт **только читает/сканирует** (git, gitleaks, pip-audit, запись артефакта). Не вызывает
|
||||
деплой-хук, не рестартит и не трогает прод-контейнер (8500/8501).
|
||||
|
||||
---
|
||||
|
||||
## Точки касания (для developer; reviewer проверяет полноту — AC-20)
|
||||
|
||||
| Модуль | Изменение |
|
||||
|--------|-----------|
|
||||
| `src/security_gate.py` (**новый leaf**) | `security_gate_applies`, `scan_secrets`, `audit_dependencies`, `classify_severity`, `compute_verdict`, `write_security_report`, `parse_security_status`, `check_security_gate`. never-raise, fail-closed на чтении вердикта. По образцу `image_freshness.py`. |
|
||||
| `src/qg/checks.py` | `check_security_gate` (тонкая обёртка, ленивый импорт) + регистрация в `QG_CHECKS`. |
|
||||
| `src/stage_engine.py` | `_handle_security_gate(...)` + врезка ПЕРВОЙ в блоке `current_stage == "deploy-staging"` (до `_handle_merge_gate`). FAIL → откат на `development`. never-raise. **`STAGE_TRANSITIONS` НЕ меняется.** |
|
||||
| `src/config.py` | `security_gate_enabled` (True), `security_gate_repos` (""), `security_dep_block_severity` ("HIGH"), `security_scan_timeout_s` (300), `security_dep_audit_fail_closed` (False), `security_secrets_block` (True) — с docstring по образцу ORCH-043/058. |
|
||||
| `Dockerfile` | Установка `gitleaks` (release-бинарь). |
|
||||
| `requirements.txt` | `pip-audit`. |
|
||||
| `.gitleaks.toml` (**новый, корень репо**) | Конфиг правил + аллоулист (`.env.example`-плейсхолдеры, тест-фикстуры) — BR-13. |
|
||||
| `.openclaw/agents/developer.md` | (Опц.) краткая инструкция про устранение security-находок при заворотах. |
|
||||
| `tests/` | `test_security_gate.py`, `test_qg_security.py`, `test_stage_engine_security_gate.py` (см. `04-test-plan.yaml`). |
|
||||
| **Документация** | `CLAUDE.md` (артефакт 17-…), `docs/architecture/README.md` (таблица гейтов + реестр QG + раздел), `CHANGELOG.md`, `.env.example` (`ORCH_SECURITY_*`), global `adr-0012`. |
|
||||
|
||||
---
|
||||
|
||||
## Альтернативы (отклонены)
|
||||
|
||||
- **Вариант R (review-стадия):** раньше/дешевле, но diff может разойтись с тем, что
|
||||
вольётся в `main`; merge-edge уже закрывает «последнюю страховку».
|
||||
- **Вариант C (CI-job через `check_ci_green`):** пороги/severity/аллоулист/артефакт плохо
|
||||
выражаются статусом коммита; коуплинг с CI-раннером. → точка расширения BR-14.
|
||||
- **fail-closed dep-audit по умолчанию:** ложные откаты при сетевых сбоях → петля. →
|
||||
только опционально через флаг.
|
||||
- **Аудит после rebase (как анкер image-freshness):** обвиняет задачу в CVE из `main` →
|
||||
петля. → скан ветки ДО merge-gate.
|
||||
- **Новая стадия `security`:** «пустая» стадия без агента не имеет триггера (как
|
||||
отклонено в ORCH-043). → под-гейт ребра.
|
||||
- **Новая колонка retry в БД:** не нужна — переиспользуем `_developer_retry_count`.
|
||||
|
||||
---
|
||||
|
||||
## Последствия
|
||||
|
||||
**Плюсы.** Структурно невозможно тихо влить секрет (безусловно) или известную CVE
|
||||
(best-effort) в `main`/прод автономной системы. Самоприменение CLAUDE.md §8. Минимальный
|
||||
blast-radius: `STAGE_TRANSITIONS`/схема БД не меняются, переиспользован готовый паттерн.
|
||||
|
||||
**Минусы / плата.** Ещё один «скрытый» под-гейт ребра (нет в `STAGE_TRANSITIONS`).
|
||||
Добавлены внешние инструменты (gitleaks-бинарь в образ, pip-audit в зависимости). Время
|
||||
сканирования добавляется к каждому прогону (ограничено таймаутом). Dep-audit best-effort
|
||||
при сетевых сбоях (осознанный компромисс против петли). v1 — Python-only (A3); мульти-стек
|
||||
и SAST — follow-up WI (BR-14).
|
||||
|
||||
**Раскат.** Сквозное изменение конвейера (новый QG + новый edge-под-гейт) → лейбл
|
||||
`arch:major-change`. Прод-деплой ORCH-022 — строго через staging-гейт (8501), без рестарта
|
||||
прод-контейнера в рамках задачи (self-hosting safety).
|
||||
|
||||
## Связи
|
||||
|
||||
adr-0006 (merge-gate — паттерн edge-под-гейта/отката), adr-0008 (image-freshness —
|
||||
условность/never-raise/fail-closed), adr-0003 (`is_self_hosting_repo` — образец условности),
|
||||
adr-0009/ORCH-061 (анти-петля ложных FAIL), ORCH-046 (дословный reason в `task_desc`),
|
||||
ORCH-9/15 (мульти-стек — будущая зависимость).
|
||||
56
docs/work-items/ORCH-022/07-infra-requirements.md
Normal file
56
docs/work-items/ORCH-022/07-infra-requirements.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# 07 — Инфраструктурные требования: Security-гейт (ORCH-022)
|
||||
|
||||
См. `06-adr/ADR-001-security-gate.md` (Р-2, Р-3, Р-8). Топология не меняется (один сервер
|
||||
mva154, Docker Compose). Новые требования — только инструменты сканирования и сетевой доступ
|
||||
к CVE-фиду.
|
||||
|
||||
## I-1. Бинарь `gitleaks` в образе
|
||||
- **Что:** статический Go-бинарь `gitleaks` (secret-scanning), устанавливается в `Dockerfile`
|
||||
(НЕ pip-пакет). Зафиксировать версию (pinned release) для детерминизма.
|
||||
- **Почему в образе, а не на хосте:** гейт исполняется внутри контейнера оркестратора
|
||||
(`advance_stage`); сканируется per-task worktree, смонтированный в контейнер.
|
||||
- **Оффлайн:** gitleaks не требует сети (правила локальны) → гарантия «секрет всегда
|
||||
блокирует» (BR-2) не зависит от доступности интернета.
|
||||
- **Контракт exit-кодов:** 0 = чисто, 1 = найдены секреты, ≥2 = ошибка инструмента
|
||||
(≥2 → never-raise degrade-вердикт гейта).
|
||||
|
||||
## I-2. `pip-audit` в зависимостях
|
||||
- **Что:** Python-пакет `pip-audit` (dependency audit), добавляется в `requirements.txt`
|
||||
(pinned-версия).
|
||||
- **Источник advisory:** OSV / PyPI advisory DB — **требует сетевого доступа** (исходящий
|
||||
HTTPS к OSV/PyPI).
|
||||
- **Цель v1:** аудит `requirements.txt` корня репо (Python-стек, A3). Мульти-стек — follow-up.
|
||||
|
||||
## I-3. Сетевой доступ к CVE-фиду (degrade-политика)
|
||||
- **Требование:** исходящий HTTPS из прод-контейнера к OSV/PyPI advisory.
|
||||
- **При недоступности (Р-3):** **fail-open + громкий warning** по умолчанию — dep-audit не
|
||||
краснит гейт из-за сетевого сбоя (анти-петля ORCH-061); фиксируется
|
||||
`deps_audit_degraded: true` + Telegram + лог. Флаг `security_dep_audit_fail_closed`
|
||||
(дефолт `false`) — для перевода в строгий режим без редеплоя кода.
|
||||
- **Секреты не зависят от сети** (I-1) — критическая гарантия безусловна.
|
||||
|
||||
## I-4. Конфиг-файлы в репозитории (версионируемые, BR-13)
|
||||
- `.gitleaks.toml` (корень репо): правила + аллоулист заведомо-безопасных совпадений
|
||||
(плейсхолдеры `.env.example`, тест-фикстуры). Версионируется, ревьюится как код.
|
||||
|
||||
## I-5. Env-флаги (`.env.example` + хост `.env`/`.env.staging`)
|
||||
| Переменная | Дефолт | Назначение |
|
||||
|------------|--------|-----------|
|
||||
| `ORCH_SECURITY_GATE_ENABLED` | `true` | глобальный kill-switch |
|
||||
| `ORCH_SECURITY_GATE_REPOS` | `` (пусто) | CSV scope; пусто → только self-hosting |
|
||||
| `ORCH_SECURITY_DEP_BLOCK_SEVERITY` | `HIGH` | порог блокировки зависимостей |
|
||||
| `ORCH_SECURITY_SCAN_TIMEOUT_S` | `300` | таймаут каждого внешнего вызова сканера |
|
||||
| `ORCH_SECURITY_DEP_AUDIT_FAIL_CLOSED` | `false` | строгий режим при недоступном фиде |
|
||||
| `ORCH_SECURITY_SECRETS_BLOCK` | `true` | секреты блокируют (всегда по дефолту) |
|
||||
|
||||
Секреты-значения в гит НЕ коммитятся (CLAUDE.md §8) — только дефолты в `.env.example`.
|
||||
|
||||
## I-6. Ресурсы и тайминги
|
||||
- Время сканирования добавляется к каждому прогону задачи на ребре `deploy-staging → deploy`,
|
||||
ограничено `ORCH_SECURITY_SCAN_TIMEOUT_S` (по образцу `merge_retest_timeout_s`).
|
||||
- Гейт исполняется ДО merge-gate/image-freshness (дёшево фейлить до дорогих rebase/rebuild).
|
||||
|
||||
## I-7. Self-hosting safety (инвариант)
|
||||
Гейт **только читает/сканирует** (git, gitleaks, pip-audit, запись артефакта). Не вызывает
|
||||
деплой-хук, не рестартит/не трогает прод-контейнер (8500/8501). Прод-деплой ORCH-022 — строго
|
||||
через staging-гейт (8501).
|
||||
26
docs/work-items/ORCH-022/08-data-requirements.md
Normal file
26
docs/work-items/ORCH-022/08-data-requirements.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# 08 — Требования к схеме БД: Security-гейт (ORCH-022)
|
||||
|
||||
## Решение: схема БД НЕ меняется
|
||||
|
||||
Миграций нет. Обоснование (соответствует TRZ §6 и паттерну edge-под-гейтов ORCH-043/058):
|
||||
|
||||
1. **Вердикт гейта — артефакт-файл** `17-security-report.md` (YAML-frontmatter), как
|
||||
`14-deploy-log.md` / `15-staging-log.md`. Не хранится в БД.
|
||||
2. **Состояние/идемпотентность** — детерминированная пересборка вердикта при каждом тике
|
||||
(гейт чистый, без долгоживущего состояния между прогонами); sentinel-файлы НЕ требуются
|
||||
(в отличие от deploy-state/post-deploy-state — там асинхронный self-restart).
|
||||
3. **Retry-счётчик** — переиспользуется существующий `_developer_retry_count(task_id)`
|
||||
(подсчёт по `jobs`/`agent_runs`), общий с merge-gate/image-freshness. **Новой колонки
|
||||
`security_retry` НЕ вводим** (TRZ §6: предпочесть подсчёт по `jobs`/`agent_runs`). Это
|
||||
корректно: security-FAIL, как merge/freshness-FAIL, откатывает на `development` и
|
||||
запускает developer — он и есть единица retry; общий cap=3 защищает от петли.
|
||||
|
||||
## Используемые существующие таблицы (без изменений)
|
||||
- `tasks` — стадия задачи (`update_task_stage` при откате на `development`).
|
||||
- `jobs` — enqueue `developer` при FAIL; основа `_developer_retry_count`.
|
||||
- `agent_runs` — usage/duration; основа подсчёта retry.
|
||||
|
||||
## Что НЕ делаем
|
||||
- Не добавляем таблицу findings/CVE-журнала (история находок — в артефактах per-task; петля
|
||||
уроков ORCH-8 читает артефакт).
|
||||
- Не добавляем колонок в `tasks`/`jobs`.
|
||||
16
docs/work-items/ORCH-022/10-tech-risks.md
Normal file
16
docs/work-items/ORCH-022/10-tech-risks.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# 10 — Технические риски: Security-гейт (ORCH-022)
|
||||
|
||||
| ID | Риск | Вероятность / Влияние | Митигация (заложена в ADR-001) |
|
||||
|----|------|----------------------|-------------------------------|
|
||||
| R-1 | **Ложные срабатывания → петля отката** `→ development` (прецедент ORCH-061 staging-loop). | Средн. / Выс. | Аллоулист `.gitleaks.toml` (BR-13); cap `MAX_DEVELOPER_RETRIES=3` → эскалация (`set_issue_blocked`+Telegram); конфигурируемый порог severity; kill-switch; UNKNOWN-severity → warning, не блок. |
|
||||
| R-2 | **Недоступность CVE-фида** даёт ложный красный/исключение. | Средн. / Выс. | fail-open + громкий warning по умолчанию (Р-3); `deps_audit_degraded:true`; флаг `security_dep_audit_fail_closed` для строгого режима. Секреты offline → не затронуты. |
|
||||
| R-3 | **Скан вешает worker-слот** (зависший gitleaks/pip-audit) → стоит конвейер всех проектов (общий инстанс, `max_concurrency`). | Низк. / Выс. | `security_scan_timeout_s` (300) на каждый внешний вызов; never-raise degrade-вердикт; гейт ПЕРВЫМ на ребре (фейлит до дорогих rebase/rebuild). |
|
||||
| R-4 | **Исключение гейта роняет `advance_stage`** → встаёт движок. | Низк. / Выс. | Двойной never-raise guard (внешний+внутренний) как `check_branch_mergeable`; AC-16/TC-11. |
|
||||
| R-5 | **Скан после rebase обвиняет задачу в CVE из `main`** → петля. | — (устранён дизайном) | Гейт исполняется ДО merge-gate (скан ветки до rebase); Р-1. |
|
||||
| R-6 | **Отсутствие бинаря `gitleaks` в образе** (забыт в Dockerfile) → гейт всегда degrade. | Низк. / Средн. | Установка в Dockerfile (I-1), pinned-версия; TC-11 (нет бинаря → `(False,reason)`, never-raise); проверяется на staging (8501) до прода. |
|
||||
| R-7 | **pip-audit без severity (UNKNOWN)** → либо ложный блок, либо пропуск. | Средн. / Средн. | UNKNOWN → warning (не блок), логируется; осознанный анти-петля компромисс; ужесточение — follow-up. |
|
||||
| R-8 | **Self-hosting: гейт трогает прод** (рестарт/деплой). | — (запрещено дизайном) | Гейт только читает/сканирует; AC-19/TC-21; прод-деплой ORCH-022 — через staging-гейт. |
|
||||
| R-9 | **Drift вердикта vs артефакта** (возврат ≠ frontmatter). | Низк. / Средн. | Единый источник: гейт пишет артефакт → читает обратно через `parse_security_status` → возвращает (Р-5); AC-8. |
|
||||
| R-10 | **Регресс существующих гейтов/стадий** (сломан `QG_CHECKS`/`STAGE_TRANSITIONS`). | Низк. / Выс. | `STAGE_TRANSITIONS` не меняется; новый чек — аддитивно в реестр; полный прогон `tests/` (TC-20); staging-гейт перед прод. |
|
||||
| R-11 | **v1 Python-only** — секреты/CVE в не-Python стеке (JS/Android) не ловятся. | — (вне scope v1, A3) | Условность scope; точка расширения мульти-стек/SAST (BR-14); зависимость ORCH-9/15 зафиксирована. |
|
||||
| R-12 | **Стоимость времени** на каждом прогоне задачи. | Низк. / Низк. | Таймаут; гейт первым (ранний выход); только self-hosting по умолчанию. |
|
||||
74
docs/work-items/ORCH-022/12-review.md
Normal file
74
docs/work-items/ORCH-022/12-review.md
Normal file
@@ -0,0 +1,74 @@
|
||||
---
|
||||
type: review
|
||||
work_item_id: ORCH-022
|
||||
verdict: APPROVED
|
||||
version: 1
|
||||
---
|
||||
|
||||
# Review ORCH-022
|
||||
|
||||
## Summary
|
||||
Security-гейт (secret-scanning `gitleaks` + dependency audit `pip-audit`) реализован как
|
||||
детерминированный под-гейт ребра `deploy-staging → deploy`, исполняемый ПЕРВЫМ среди
|
||||
edge-под-гейтов — в точности по ADR-001 (Вариант M) и эталонному паттерну соседей
|
||||
(merge-gate ORCH-043 / image-freshness ORCH-058): leaf-модуль `src/security_gate.py`
|
||||
(never-raise) + тонкая обёртка `check_security_gate` в `QG_CHECKS` (lazy-import, нет цикла)
|
||||
+ врезка `_handle_security_gate` ПЕРВОЙ в блоке `current_stage == "deploy-staging"`.
|
||||
`STAGE_TRANSITIONS` и схема БД не тронуты. Все 772 теста зелёные (25 из них —
|
||||
security-специфичные: `test_security_gate.py`, `test_qg_security.py`,
|
||||
`test_stage_engine_security_gate.py`). Документация обновлена полностью и в этом же PR.
|
||||
|
||||
### Соответствие ТЗ (02-trz)
|
||||
- FR-1 secret-scan offline `origin/main..HEAD`, любой секрет вне аллоулиста → FAIL ✓
|
||||
- FR-2 dep-audit по severity (`HIGH` дефолт), MEDIUM/LOW/UNKNOWN → warning ✓
|
||||
- FR-3 машинный вердикт ТОЛЬКО из frontmatter `17-security-report.md`, negative-токен
|
||||
авторитетен, write→read-back (единый источник истины) ✓
|
||||
- FR-4 FAIL → откат на `development` + developer-retry (cap 3) + `task_desc` с дословными
|
||||
находками (ORCH-046) ✓
|
||||
- FR-5 условность `security_gate_enabled` / `security_gate_repos` (пусто → self-hosting) ✓
|
||||
- FR-6 never-raise + таймаут `security_scan_timeout_s` ✓
|
||||
- FR-7 наблюдаемость (Telegram при degraded/FAIL, лог при PASS) ✓
|
||||
- §6 без миграций БД, §7 инварианты соблюдены (STAGE_TRANSITIONS/QG_CHECKS консистентны,
|
||||
gate не деплоит/не рестартит прод) ✓
|
||||
|
||||
### Соответствие ADR (06-adr/ADR-001 + global adr-0012)
|
||||
Р-1 (размещение ПЕРВЫМ, до merge-gate, до захвата merge-lease → lease не освобождается),
|
||||
Р-2 (gitleaks pinned Go-бинарь в Dockerfile, pip-audit в requirements), Р-3 (fail-open
|
||||
degrade + флаг `security_dep_audit_fail_closed`), Р-4 (пороги, UNKNOWN→warning), Р-5
|
||||
(артефакт + read-back), Р-6 (откат/cap/эскалация), Р-7 (lazy-import, double-guard
|
||||
never-raise), Р-8 (self-hosting safety) — все реализованы как описано.
|
||||
|
||||
### Критерии приёмки (03)
|
||||
AC-1..AC-21 покрыты тестами TC-01..TC-21 (incl. rollback TC-16, verbatim task_desc TC-17,
|
||||
cap+blocked TC-18, PASS-advance TC-19, no-deploy-on-FAIL TC-21). AC-20 (документация) —
|
||||
подтверждён ниже.
|
||||
|
||||
## Findings
|
||||
|
||||
### P0 — Blocker
|
||||
- нет
|
||||
|
||||
### P1 — Must fix
|
||||
- нет
|
||||
|
||||
### P2 — Should fix
|
||||
- нет
|
||||
|
||||
### P3 — Nice-to-have
|
||||
- Глобальный `docs/architecture/adr/adr-0012-security-gate.md` помечен `Статус: proposed`,
|
||||
тогда как per-WI `06-adr/ADR-001` — `Accepted`. Косметическая рассинхронизация статуса,
|
||||
на функциональность/гейты не влияет.
|
||||
|
||||
## Документация
|
||||
Обновлена в том же PR (AC-20, CLAUDE.md §6 соблюдён):
|
||||
- `CLAUDE.md` — раздел «Артефакты задачи» (добавлен `17-security-report.md`) + строка о
|
||||
машинных вердиктах (`security_status:`).
|
||||
- `docs/architecture/README.md` — реестр `QG_CHECKS` (`check_security_gate (ORCH-022)`),
|
||||
новый раздел «Security-гейт …», статусная сноска внизу.
|
||||
- `docs/architecture/adr/adr-0012-security-gate.md` — новый global ADR (+ per-WI ADR-001).
|
||||
- `CHANGELOG.md` — подробная запись в `[Unreleased] / Added`.
|
||||
- `.env.example` — все шесть `ORCH_SECURITY_*` с комментариями.
|
||||
- `Dockerfile` (pinned gitleaks), `requirements.txt` (pip-audit), `.gitleaks.toml` (корень,
|
||||
правила + аллоулист) — инфраструктура версионирована.
|
||||
|
||||
Статус: документация = golden source — синхронна с кодом. Замечаний нет.
|
||||
76
docs/work-items/ORCH-022/13-test-report.md
Normal file
76
docs/work-items/ORCH-022/13-test-report.md
Normal file
@@ -0,0 +1,76 @@
|
||||
---
|
||||
type: test-report
|
||||
work_item_id: ORCH-022
|
||||
result: PASS
|
||||
---
|
||||
|
||||
# Test Report — ORCH-022
|
||||
|
||||
Security-гейт: secret-scanning (gitleaks) + dependency audit (pip-audit) как под-гейт
|
||||
ребра `deploy-staging → deploy`.
|
||||
|
||||
## Окружение
|
||||
- Python: 3.12.13
|
||||
- pytest: 8.3.3
|
||||
- Дата: 2026-06-07
|
||||
- Ветка: `feature/ORCH-022-security-secret-scanning`
|
||||
- Review verdict: APPROVED (`12-review.md`)
|
||||
|
||||
## Smoke test API (prod 8500, self-hosting — не трогаем контейнер)
|
||||
| Endpoint | Результат |
|
||||
|----------|-----------|
|
||||
| `GET /health` | `{"status":"ok","service":"orchestrator"}` — OK |
|
||||
| `GET /status` | OK (active task ORCH-022 в stage=testing виден) |
|
||||
| `GET /queue` | OK (counts/resilience/reconcile/reaper/post_deploy присутствуют) |
|
||||
|
||||
## Результаты (привязка к 04-test-plan.yaml)
|
||||
|
||||
| TC ID | Описание | Тест | Результат |
|
||||
|-------|----------|------|-----------|
|
||||
| TC-01 | Секрет в diff → FAIL, secrets_found>=1, причина называет находку | test_security_gate.py::test_tc01_secret_in_diff_fails | PASS |
|
||||
| TC-02 | Чистая ветка → PASS, secrets_found=0 | test_tc02_clean_branch_passes | PASS |
|
||||
| TC-03 | Аллоулист подавляет заведомо-безопасное | test_tc03_allowlisted_match_does_not_fail | PASS |
|
||||
| TC-04 | HIGH/CRITICAL CVE при пороге HIGH → FAIL, deps_blocking>=1 | test_tc04_high_cve_at_high_threshold_blocks | PASS |
|
||||
| TC-05 | Только MEDIUM/LOW → PASS, deps_warning>=1 | test_tc05_only_medium_low_warns_passes | PASS |
|
||||
| TC-06 | Конфиг порога severity влияет на классификацию | test_tc06_threshold_config_changes_classification | PASS |
|
||||
| TC-07 | Недоступный фид → детерминированный degrade (fail-open default / fail-closed strict) | test_tc07_degraded_feed_failopen_default_failclosed_strict | PASS |
|
||||
| TC-08 | Вердикт ТОЛЬКО из frontmatter; negative-токен авторитетен | test_tc08_verdict_only_from_frontmatter | PASS |
|
||||
| TC-09 | Нет/битый frontmatter → (False, reason) fail-closed | test_tc09_missing_or_broken_frontmatter_failclosed | PASS |
|
||||
| TC-10 | Артефакт 17-security-report.md с валидным frontmatter + телом | test_tc10_artifact_has_valid_frontmatter_and_body | PASS |
|
||||
| TC-11 | Нет бинаря / исключение → (False, reason), never-raise | test_tc11_missing_binary_failclosed_never_raises | PASS |
|
||||
| TC-12 | Таймаут → детерминированный fail-closed, без зависания | test_tc12_timeout_is_deterministic_failclosed | PASS |
|
||||
| TC-13 | Не-self репо при пустом scope → (True, N/A) мгновенно | test_qg_security.py::test_tc13_non_self_repo_empty_scope_is_na | PASS |
|
||||
| TC-14 | ORCH_SECURITY_GATE_ENABLED=false → no-op pass | test_tc14_disabled_is_noop_pass | PASS |
|
||||
| TC-15 | Зарегистрирован в QG_CHECKS и диспетчеризуется _run_qg | test_tc15_registered_in_qg_checks / test_tc15_dispatched_by_run_qg | PASS |
|
||||
| TC-16 | FAIL → откат на development, enqueue developer, notify_qg_failure | test_stage_engine_security_gate.py::test_tc16_fail_rolls_back_and_enqueues_developer | PASS |
|
||||
| TC-17 | task_desc несёт дословную причину (ORCH-046) | test_tc17_task_desc_has_verbatim_findings | PASS |
|
||||
| TC-18 | После MAX_DEVELOPER_RETRIES (3) → set_issue_blocked + Telegram | test_tc18_retry_cap_blocks_and_alerts | PASS |
|
||||
| TC-19 | PASS → штатное продвижение конвейера | test_tc19_pass_advances_normally | PASS |
|
||||
| TC-20 | STAGE_TRANSITIONS не изменён; тесты стадий зелёные | tests/test_stages.py (полный прогон) | PASS |
|
||||
| TC-21 | Гейт не вызывает деплой-хук/рестарт прод (self-hosting safety) | test_tc21_fail_never_triggers_deploy | PASS |
|
||||
|
||||
Все 21 TC покрыты и зелёные. Соответствие критериям приёмки (03-acceptance-criteria):
|
||||
AC-1..AC-21 закрыты соответствующими TC (AC-N ↔ TC-N для N=1..21; AC-20 «документация»
|
||||
подтверждён в review 12-review.md).
|
||||
|
||||
## Вывод pytest
|
||||
|
||||
### Security-специфичные тесты (25 шт.)
|
||||
```
|
||||
tests/test_security_gate.py ............... (15)
|
||||
tests/test_qg_security.py ...... (6)
|
||||
tests/test_stage_engine_security_gate.py ..... (5)
|
||||
======================== 25 passed, 1 warning in 0.49s =========================
|
||||
```
|
||||
|
||||
### Полный регресс
|
||||
```
|
||||
======================= 772 passed, 1 warning in 14.70s ========================
|
||||
```
|
||||
(1 warning — PydanticDeprecatedSince20 в src/config.py, не связан с ORCH-022,
|
||||
существовал до задачи.)
|
||||
|
||||
## Итог
|
||||
**PASS** — полный регресс 772/772 зелёный, 25 security-тестов покрывают все 21 TC
|
||||
плана и AC-1..AC-21, smoke-тесты API прод-инстанса OK. Прод-контейнер в процессе
|
||||
тестирования не затронут (тесты офлайн/изолированы). Задача готова к стадии deploy-staging.
|
||||
12
docs/work-items/ORCH-022/14-deploy-log.md
Normal file
12
docs/work-items/ORCH-022/14-deploy-log.md
Normal file
@@ -0,0 +1,12 @@
|
||||
---
|
||||
deploy_status: SUCCESS
|
||||
work_item: ORCH-022
|
||||
hook_exit_code: 0
|
||||
deployed_by: deploy-finalizer
|
||||
---
|
||||
|
||||
# Deploy log — ORCH-036 executable self-deploy
|
||||
|
||||
Прод-деплой завершён хост-хуком с exit-code `0` -> `deploy_status: SUCCESS`.
|
||||
|
||||
Вердикт зафиксирован детерминированным finalizer'ом (Фаза C), не LLM.
|
||||
30
docs/work-items/ORCH-022/15-staging-log.md
Normal file
30
docs/work-items/ORCH-022/15-staging-log.md
Normal file
@@ -0,0 +1,30 @@
|
||||
---
|
||||
staging_status: SUCCESS
|
||||
timestamp: 2026-06-07T18:02:27+00:00
|
||||
base_url: http://localhost:8501
|
||||
---
|
||||
|
||||
# Staging Gate Log
|
||||
|
||||
Staging test suite completed via canonical run (ORCH-048, ADR-001):
|
||||
|
||||
```
|
||||
docker exec orchestrator-staging \
|
||||
python3 /repos/orchestrator/scripts/staging_check.py \
|
||||
--base-url http://localhost:8501 --mode stub
|
||||
```
|
||||
|
||||
**Result: 8/10 checks PASS — exit code 0 (advance).**
|
||||
|
||||
All REAL (pipeline) checks green: A1, A2, A3 (SMOKE), B4, B5, B6 (ACCESS), C7, C8 (E2E).
|
||||
|
||||
Two sandbox-infra-only checks failed and were waived per ORCH-061
|
||||
(`staging_infra_tolerance_enabled=True`) — these depend on SANDBOX bot accounts
|
||||
being members of the SANDBOX Plane project, not on the pipeline:
|
||||
|
||||
```
|
||||
INFRA-WAIVED: C9a Branch appears in orchestrator-sandbox, C9b Analyst job enqueued in staging queue (known sandbox-infra; real checks green)
|
||||
VERDICT: SUCCESS (exit 0) — SUCCESS (infra-waived): ['C9a Branch appears in orchestrator-sandbox', 'C9b Analyst job enqueued in staging queue'] are known sandbox-infra checks; all real checks green
|
||||
```
|
||||
|
||||
Cleanup ran (Plane SANDBOX test issue deleted, HTTP 204). Exit code 0 → `staging_status: SUCCESS`.
|
||||
14
docs/work-items/ORCH-022/16-post-deploy-log.md
Normal file
14
docs/work-items/ORCH-022/16-post-deploy-log.md
Normal file
@@ -0,0 +1,14 @@
|
||||
---
|
||||
post_deploy_status: HEALTHY
|
||||
action_taken: NONE
|
||||
work_item: ORCH-022
|
||||
window_s: 900
|
||||
checks_total: 30
|
||||
checks_failed: 0
|
||||
---
|
||||
|
||||
# Post-deploy log — ORCH-021 post-deploy monitor
|
||||
|
||||
Наблюдение прода завершено: `post_deploy_status: HEALTHY`, `action_taken: NONE`.
|
||||
|
||||
Окно наблюдения: 900s; опросов всего: 30, из них с провалом: 0.
|
||||
7
docs/work-items/ORCH-065/00-business-request.md
Normal file
7
docs/work-items/ORCH-065/00-business-request.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Business Request: BUG: zombie jobs + merge-lease залип (процесс умер, статус running)
|
||||
|
||||
Work Item ID: ORCH-065
|
||||
|
||||
## Description
|
||||
|
||||
TBD
|
||||
103
docs/work-items/ORCH-065/01-brd.md
Normal file
103
docs/work-items/ORCH-065/01-brd.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# BRD — ORCH-065: zombie jobs + залипший merge-lease
|
||||
|
||||
Work Item ID: ORCH-065
|
||||
Тип: BUG (P0)
|
||||
Репозиторий: orchestrator (self-hosting)
|
||||
Эпик: блокер ORCH-54 (полностью автономный self-deploy)
|
||||
|
||||
## 1. Контекст и проблема
|
||||
|
||||
Оркестратор — единый инстанс с **общей БД и общей очередью** (`jobs`,
|
||||
`max_concurrency=1` для self-hosting), обслуживающий несколько проектов. Финальная
|
||||
автономность self-deploy упирается в два связанных класса отказов, оба сводящиеся
|
||||
к «процесс умер/завершился, а состояние осталось захваченным навсегда»:
|
||||
|
||||
### Проблема A — zombie jobs (строка `jobs` навсегда `running`)
|
||||
Агент (deployer/developer/reviewer) завершается **или умирает** (краш, OOM,
|
||||
рестарт контейнера в ходе self-deploy, гибель monitor-потока), но строка в таблице
|
||||
`jobs` остаётся в статусе `running`. Финализация статуса job выполняется **только**
|
||||
в `_monitor_agent` → `_finalize_job` внутри того же процесса; если этот поток/процесс
|
||||
не доживает до финализации — job «зомбирован».
|
||||
|
||||
- Единственная имеющаяся защита — `requeue_running_jobs()` в `main.lifespan`,
|
||||
срабатывающая **исключительно на старте процесса**. Зомби, возникший **без**
|
||||
рестарта (умер дочерний процесс/monitor-поток, а сервис жив), не реанимируется
|
||||
никогда.
|
||||
- При `max_concurrency=1` одна зомби-строка `running` блокирует claim всех
|
||||
последующих job (`count_running_jobs() >= max_concurrency` → claim не происходит)
|
||||
→ **встаёт конвейер всех проектов**.
|
||||
|
||||
### Проблема B — залипший merge-lease
|
||||
Merge-gate (ORCH-043) берёт файловый lease `<repos_dir>/.merge-lease-<repo>.json`
|
||||
ПЕРЕД rebase+re-test и держит его до фактического merge PR в `main`. Если процесс
|
||||
умирает на финальном merge **с зажатым lease**:
|
||||
|
||||
- Реклейм lease реализован **лениво и только по возрасту** (`age >=
|
||||
merge_lock_timeout_s`) и **только в момент `acquire_merge_lease` другой задачей**.
|
||||
Проактивного освобождения (на старте / периодически) нет; **liveness держателя по
|
||||
pid не проверяется** (хотя `pid` в lease пишется).
|
||||
- Пострадавшая задача сама re-drive не получает: merge не финализируется → задача
|
||||
висит, lease мешает чужим merge до истечения TTL.
|
||||
|
||||
### Проблема C — неидемпотентная финализация merge
|
||||
Если rebase+re-test прошли зелёно (ветка догнана и проверена), но процесс умер до
|
||||
завершения слияния PR — повторного «докатывания» merge нет. Задача застревает в
|
||||
полу-выполненном состоянии, хотя вся дорогая работа (rebase+re-test) уже сделана.
|
||||
|
||||
## 2. Бизнес-последствия
|
||||
- **Это ПОСЛЕДНЯЯ ручная точка автономного деплоя.** Без фикса ни одна self-hosting
|
||||
задача не доезжает до прода без оператора (cancel zombie + ручной merge PR +
|
||||
ручной `--deploy`).
|
||||
- Прямой блокер эпика ORCH-54.
|
||||
- Доказанные инциденты (07.06): ORCH-58/60/61/21 — каждый раз после успешного
|
||||
deployer-прохода job оставался `running`; jobs **236/239/242/254** — зомби,
|
||||
прод-merge/deploy доводились вручную.
|
||||
- Групповой риск: зомби в общей очереди при concurrency=1 останавливает конвейер
|
||||
enduro-trails и всех прочих проектов.
|
||||
|
||||
## 3. Цель
|
||||
Сделать так, чтобы **смерть процесса/потока на любой стадии (включая self-restart
|
||||
во время deploy) НЕ оставляла навсегда захваченных ресурсов** — ни строки `jobs` в
|
||||
`running`, ни merge-lease. Конвейер должен самовосстанавливаться без оператора, при
|
||||
этом сохраняя все инварианты self-hosting (не ронять прод-контейнер, не трогать
|
||||
`main`, fail-closed на реальных ошибках).
|
||||
|
||||
## 4. Объём (Scope)
|
||||
|
||||
### В объёме
|
||||
1. **Job-reaper** — фоновый watchdog (паттерн `reconciler`/`queue_worker`),
|
||||
детектирующий «мёртвый» `running`-job и приводящий его строку в корректный
|
||||
терминальный/повторный статус (`done`/`failed`/`queued`) детерминированно,
|
||||
без LLM. Restart-safe и работающий **без** рестарта процесса.
|
||||
2. **Проактивный реклейм stale merge-lease** — освобождение lease, чей держатель
|
||||
мёртв (pid не жив) ИЛИ возраст превысил TTL — на старте и периодически (reaper/
|
||||
reconciler), а не только лениво при чужом `acquire`.
|
||||
3. **Идемпотентная финализация merge** — если rebase+re-test зелёные, но merge не
|
||||
состоялся, операция повторяется/докатывается без потери уже сделанной работы.
|
||||
|
||||
### Вне объёма
|
||||
- Переход на внешний брокер очередей / смену схемы блокировок merge на БД-lock.
|
||||
- Полный авто-approve деплоя (ORCH-54) — отдельная задача; здесь только снятие
|
||||
технического блокера.
|
||||
- Изменение конвейера стадий (`STAGE_TRANSITIONS`) и реестра гейтов как контрактов.
|
||||
|
||||
## 5. Заинтересованные стороны
|
||||
- Owner оркестратора (self-hosting автономность).
|
||||
- Все проекты на общем инстансе (enduro-trails и пр.) — страдают от блокировки
|
||||
общей очереди.
|
||||
|
||||
## 6. Допущения и ограничения
|
||||
- `max_concurrency=1` для self-hosting сохраняется.
|
||||
- Self-hosting safety (CLAUDE.md): нельзя ронять/рестартить прод-контейнер в рамках
|
||||
задачи; нельзя пушить/форс-пушить `main`; реклейм lease не должен прерывать
|
||||
легитимно работающий merge.
|
||||
- Никаких ложных реанимаций: живой, но долгий job не должен помечаться зомби
|
||||
(нужен порог/грейс «N тиков» + проверка реальной смерти, а не просто долготы).
|
||||
- Контракт **never-raise** для всей новой фоновой логики (как у reconciler/merge_gate).
|
||||
- Kill-switch на каждый новый механизм (как `reconcile_enabled` / `merge_gate_enabled`).
|
||||
|
||||
## 7. Критерий успеха (бизнес-уровень)
|
||||
После фикса воспроизводимый сценарий «успешный deployer-проход + смерть процесса/
|
||||
self-restart» НЕ оставляет зомби-job и зажатого lease: задача либо корректно
|
||||
доезжает до `done` сама, либо откатывается по штатному контракту — **без участия
|
||||
оператора**. Регресс-тест на jobs-зомби и stale-lease зелёный.
|
||||
170
docs/work-items/ORCH-065/02-trz.md
Normal file
170
docs/work-items/ORCH-065/02-trz.md
Normal file
@@ -0,0 +1,170 @@
|
||||
# ТЗ — ORCH-065: job-reaper + stale merge-lease reclaim + идемпотентный merge
|
||||
|
||||
Work Item ID: ORCH-065
|
||||
Базируется на: 01-brd.md
|
||||
Примечание архитектору: ТЗ фиксирует ТРЕБОВАНИЯ и кандидатные модули. Выбор
|
||||
конкретной реализации (новый модуль vs расширение reconciler; колонка `jobs.pid`
|
||||
vs эвристика на `agent_runs`) — за стадией architecture (ADR). Если какая-либо
|
||||
часть ТЗ окажется нереализуемой/спорной — вернуть в Анализ, не комментировать
|
||||
задним числом.
|
||||
|
||||
## 0. Текущее состояние (факты из кода)
|
||||
|
||||
| Место | Факт |
|
||||
|-------|------|
|
||||
| `src/queue_worker.py` `_drain_once` | claim не происходит, пока `count_running_jobs() >= max_concurrency`. Одна зомби-строка `running` при concurrency=1 блокирует всю очередь. |
|
||||
| `src/agents/launcher.py` `_monitor_agent` → `_finalize_job` | статус job (`done`/`queued`/`failed`) выставляется ТОЛЬКО в этом monitor-потоке. Смерть потока/процесса до финализации ⇒ job навсегда `running`. |
|
||||
| `src/main.py` (lifespan, строки ~55-61) | `requeue_running_jobs()` вызывается ТОЛЬКО при старте процесса. |
|
||||
| `src/db.py` `requeue_running_jobs` | flip всех `running`→`queued`. Без рестарта не запускается. |
|
||||
| `src/db.py` таблица `jobs` | колонки `pid`/`heartbeat` НЕТ; есть `run_id`, `started_at`, `attempts`, `max_attempts`. |
|
||||
| `src/merge_gate.py` `acquire_merge_lease` | реклейм stale lease (age `>= merge_lock_timeout_s`) и corrupt — ТОЛЬКО лениво в момент `acquire`. Lease пишет `pid`, но liveness по pid нигде не проверяется. |
|
||||
| `src/merge_gate.py` `release_merge_lease` | holder-aware (по `branch`), идемпотентен. Вызовы: `webhooks/gitea.py:380` (PR-merged), `stage_engine.py:352/740/876/952`, `qg/checks.py:683/691/697`. |
|
||||
| `src/qg/checks.py` `check_branch_mergeable` | при SUCCESS lease ДЕРЖИТСЯ до фактического merge PR. Если процесс умрёт после этого — lease зажат. |
|
||||
| `src/reconciler.py` | паттерн-образец фонового daemon-потока (never-raise, kill-switch, observability в `/queue`). |
|
||||
|
||||
## 1. Задействованные модули `src/`
|
||||
|
||||
- `src/db.py` — новые job-запросы для reaper (выборка stale running-job, атомарный
|
||||
reap). Возможна lightweight-миграция (см. §3).
|
||||
- **Job-reaper** — НОВЫЙ модуль (кандидат `src/job_reaper.py`) ИЛИ расширение
|
||||
`src/reconciler.py`. Решение — за архитектором; ТЗ требует daemon-поток по образцу
|
||||
`reconciler` (never-raise, `_stop`-Event, старт/стоп в `main.lifespan`, снимок в
|
||||
`/queue`).
|
||||
- `src/merge_gate.py` — функция проактивного реклейма stale/dead lease (по pid-
|
||||
liveness + по TTL); helper проверки liveness pid; helper идемпотентной
|
||||
финализации merge.
|
||||
- `src/main.py` — старт/стоп нового daemon-потока в `lifespan` (после `worker.start()`
|
||||
/ `reconciler.start()`, симметрично остановка перед `worker.stop()`); вызов
|
||||
стартового реклейма stale-lease рядом с `requeue_running_jobs()`.
|
||||
- `src/config.py` — новые настройки/флаги (см. §5).
|
||||
- `src/main.py` `GET /queue` — блок наблюдаемости reaper (образец `reconcile`/
|
||||
`post_deploy`).
|
||||
|
||||
## 2. Функциональные требования
|
||||
|
||||
### FR-1. Job-reaper (Проблема A)
|
||||
- Фоновый поток периодически (`reaper_interval_s`) сканирует строки `jobs` в статусе
|
||||
`running`.
|
||||
- Для каждого `running`-job определяет, **жив ли его исполнитель**. «Мёртвым» job
|
||||
считается, когда выполнено и устойчиво (см. FR-1.3) хотя бы одно из:
|
||||
- процесс агента (по pid/run_id) не существует, а финализация не произошла;
|
||||
- `agent_runs` строки run_id имеет `finished_at`/`exit_code` (процесс реально
|
||||
завершился), но `jobs.status` всё ещё `running` (monitor умер между записью
|
||||
exit_code и `_finalize_job`);
|
||||
- job висит `running` дольше предохранительного потолка
|
||||
`reaper_max_running_s` (заведомо больше любого легитимного `agent_timeout` +
|
||||
grace) — backstop на случай, когда liveness определить нельзя.
|
||||
- FR-1.2 Действие при подтверждённой смерти:
|
||||
- если есть достоверный успешный исход (`agent_runs.exit_code == 0`) — довести job
|
||||
к корректному завершению **через тот же контракт**, что `_finalize_job`
|
||||
(включая, при необходимости, повторную попытку auto-advance) — НЕ дублировать
|
||||
переход, если он уже произошёл (идемпотентность через `has_active_job_for_task`
|
||||
/ проверку стадии);
|
||||
- если исход неуспешный/неизвестен и бюджет попыток не исчерпан
|
||||
(`attempts < max_attempts`) — `queued` (повторная постановка), как делает
|
||||
`requeue_running_jobs`;
|
||||
- если бюджет исчерпан — `failed` + Telegram-алерт.
|
||||
- FR-1.3 **Анти-ложноположительность.** Job помечается зомби только после
|
||||
устойчивого подтверждения смерти: процесс мёртв на протяжении `reaper_dead_ticks`
|
||||
последовательных тиков (≥2) ИЛИ превышен `reaper_max_running_s`. Живой долгий
|
||||
агент (в пределах своего `agent_timeout`) НИКОГДА не реапится.
|
||||
- FR-1.4 Работает **без рестарта** процесса (главное отличие от существующего
|
||||
`requeue_running_jobs`).
|
||||
- FR-1.5 Restart-safe: после рестарта поведение корректно совмещается со стартовым
|
||||
`requeue_running_jobs()` (нет двойной обработки одной строки; атомарность reap-
|
||||
UPDATE с guard по `status='running'`, как в `claim_next_job`).
|
||||
|
||||
### FR-2. Проактивный реклейм stale/dead merge-lease (Проблема B)
|
||||
- FR-2.1 На старте процесса (рядом с `requeue_running_jobs()` в `lifespan`) и
|
||||
периодически в фоновом потоке: для каждого репо с merge-gate проверить lease и
|
||||
освободить его, если держатель **мёртв** или lease **просрочен**.
|
||||
- FR-2.2 «Держатель мёртв» = pid из lease не существует в системе (liveness-проба,
|
||||
напр. `os.kill(pid, 0)` с обработкой `ProcessLookupError`/`PermissionError`),
|
||||
при условии что pid принадлежит этому хосту/неймспейсу. «Просрочен» = `age >=
|
||||
merge_lock_timeout_s` (существующий TTL-контракт сохраняется).
|
||||
- FR-2.3 Реклейм **holder-aware и безопасен**: НЕ освобождать lease, чей держатель
|
||||
жив и в пределах TTL (защита легитимного merge). Логировать `warning` при каждом
|
||||
реклейме (наблюдаемость, как сейчас в `acquire_merge_lease`).
|
||||
- FR-2.4 Условность как ORCH-35/43: реально только для self-hosting/`merge_gate_repos`;
|
||||
прочие репо — no-op.
|
||||
- FR-2.5 Контракт **never-raise**; любая ошибка реклейма не должна валить поток.
|
||||
|
||||
### FR-3. Идемпотентная финализация merge (Проблема C)
|
||||
- FR-3.1 Если ветка прошла rebase+re-test (догнана до `origin/main` и зелёная), но
|
||||
merge PR не состоялся из-за смерти процесса — система должна **докатить/повторить**
|
||||
merge без повторного прогона дорогих шагов, когда это безопасно.
|
||||
- FR-3.2 Финализация merge должна быть **идемпотентной**: повторный вызов при уже
|
||||
слитом PR — no-op (определять по состоянию PR в Gitea и/или по
|
||||
`branch_is_behind_main`/состоянию `main`), без ошибки и без второго слияния.
|
||||
- FR-3.3 Восстановление re-drive обеспечивается штатными механизмами (reaper
|
||||
довёл job до `queued` → повторный проход стадии `deploy`/merge-gate; либо
|
||||
reconciler доигрывает переход). Дублирующая логика merge НЕ создаётся — переиспользуются
|
||||
существующие пути (`check_branch_mergeable` / deployer-merge).
|
||||
- FR-3.4 При повторе lease берётся заново (идемпотентный re-acquire «held by self»
|
||||
по branch уже поддержан в `acquire_merge_lease`).
|
||||
|
||||
### FR-4. Наблюдаемость
|
||||
- FR-4.1 Блок `reaper` в `GET /queue`: enabled, interval, last_run_ts, reaped_total,
|
||||
last_reaped (job_id/agent), lease_reclaimed_total (best-effort, как у reconciler).
|
||||
- FR-4.2 Каждый reap и каждый lease-reclaim — `logger.warning` с идентификаторами
|
||||
(job_id, run_id, pid, repo, branch).
|
||||
- FR-4.3 При reap→`failed` и при lease-reclaim — Telegram (как существующие алерты).
|
||||
|
||||
## 3. Изменения схемы БД
|
||||
- Текущая `jobs` НЕ содержит `pid`. Для надёжной pid-liveness job-reaper'у, скорее
|
||||
всего, потребуется **lightweight-миграция**: добавить `jobs.pid INTEGER` (через
|
||||
`_ensure_column`, идемпотентно, безопасно на live prod DB — паттерн уже
|
||||
применяется в `db.py`). pid проставляется в `_spawn` рядом с `run_id`/`started_at`.
|
||||
- **Альтернатива без миграции** (на усмотрение архитектора): определять смерть по
|
||||
`agent_runs.finished_at/exit_code` + потолку `reaper_max_running_s`, без хранения
|
||||
pid в `jobs`. ADR должен зафиксировать выбор и обоснование.
|
||||
- Реестры `STAGE_TRANSITIONS` и `QG_CHECKS` — **без изменений** (новых стадий/гейтов
|
||||
не вводим; reaper и lease-reclaim — фоновые механизмы, не стадии).
|
||||
- Merge-lease остаётся файловым (`.merge-lease-<repo>.json`); схема файла lease
|
||||
не меняется (pid и acquired_at уже есть).
|
||||
|
||||
## 4. Изменения API
|
||||
- `GET /queue` — добавить блок `reaper` (read-only наблюдаемость). Прочие endpoints
|
||||
без изменений. Новых webhook-роутов нет.
|
||||
|
||||
## 5. Конфигурация / kill-switches (`src/config.py`)
|
||||
Именование — по образцу `reconcile_*` / `merge_*`. Кандидаты (точные имена/дефолты
|
||||
уточняет архитектор):
|
||||
|
||||
| Настройка | Назначение | Дефолт (предложение) |
|
||||
|-----------|-----------|----------------------|
|
||||
| `reaper_enabled` | глобальный kill-switch job-reaper | `true` |
|
||||
| `reaper_interval_s` | период сканирования | `60` |
|
||||
| `reaper_dead_ticks` | сколько подряд тиков pid должен быть мёртв перед reap | `2` |
|
||||
| `reaper_max_running_s` | потолок «running» (backstop), > max agent_timeout+grace | `3600` |
|
||||
| `lease_reclaim_enabled` | kill-switch проактивного реклейма lease | `true` |
|
||||
| (переиспользуется) `merge_lock_timeout_s` | TTL lease | `300` (как есть) |
|
||||
| (переиспользуется) `merge_gate_repos` | область применения lease-reclaim | как есть |
|
||||
|
||||
Все флаги — пробрасываются из env (`ORCH_*`), `false` → строго прежнее поведение.
|
||||
|
||||
## 6. Требования к QG checks
|
||||
- Новых QG checks НЕ вводить (это фоновые resilience-механизмы, не гейты выхода со
|
||||
стадии). `check_branch_mergeable` остаётся контрактно неизменным; допускается лишь
|
||||
переиспользование его как идемпотентного пути финализации merge (FR-3.3).
|
||||
|
||||
## 7. Артефакты pipeline, создаваемые/обновляемые в ЭТОМ PR
|
||||
- Код: см. §1.
|
||||
- `06-adr/ADR-001-*.md` — архитектурное решение (где живёт reaper; pid-колонка vs
|
||||
эвристика; механизм идемпотентного merge) — создаёт architect.
|
||||
- `docs/architecture/README.md` — новый раздел про job-reaper + lease-reclaim
|
||||
(golden-source, в этом же PR).
|
||||
- `docs/architecture/internals.md` — детали (если затрагивается схема БД / потоки).
|
||||
- `CHANGELOG.md` — запись ORCH-065.
|
||||
- `.env.example` — новые `ORCH_*` флаги (канон секретов/настроек).
|
||||
- `docs/operations/INFRA.md` — упоминание поведения при self-restart, если
|
||||
затрагивается (best-effort).
|
||||
|
||||
## 8. Инварианты (НЕ нарушать)
|
||||
- Не ронять/не рестартить прод-контейнер `orchestrator` в рамках задачи.
|
||||
- Никогда не пушить/форс-пушить `main`; реклейм lease не инициирует git-операций.
|
||||
- `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, контракты `check_*`, БАГ-8 откат,
|
||||
exit-коды deploy-хука — без изменений.
|
||||
- never-raise на единицу фоновой работы; идемпотентность; restart-safe; тишина при
|
||||
отсутствии аномалий (как reconciler).
|
||||
- Анти-ложноположительность (FR-1.3): живой долгий агент не реапится.
|
||||
122
docs/work-items/ORCH-065/03-acceptance-criteria.md
Normal file
122
docs/work-items/ORCH-065/03-acceptance-criteria.md
Normal file
@@ -0,0 +1,122 @@
|
||||
# Критерии приёмки — ORCH-065
|
||||
|
||||
Work Item ID: ORCH-065
|
||||
Формат: каждый критерий имеет явное условие PASS/FAIL. Все критерии должны быть
|
||||
PASS для прохождения review/testing.
|
||||
|
||||
## A. Job-reaper (Проблема A)
|
||||
|
||||
### AC-1 — реап мёртвого running-job без рестарта
|
||||
- PASS: при наличии строки `jobs` в статусе `running`, чей процесс/исполнитель
|
||||
достоверно мёртв (pid не существует ИЛИ `agent_runs.exit_code` записан, а job всё
|
||||
ещё `running`) и условие устойчивости (FR-1.3) выполнено, фоновый reaper переводит
|
||||
строку в корректный статус (`done`/`queued`/`failed`) **без перезапуска процесса**.
|
||||
- FAIL: строка остаётся `running` после `reaper_dead_ticks` тиков / превышения
|
||||
`reaper_max_running_s`.
|
||||
|
||||
### AC-2 — разблокировка очереди при concurrency=1
|
||||
- PASS: после реапа зомби-строки `count_running_jobs()` снижается, и следующий
|
||||
queued-job успешно claim'ится воркером.
|
||||
- FAIL: очередь остаётся заблокированной зомби-строкой.
|
||||
|
||||
### AC-3 — анти-ложноположительность (живой долгий агент не реапится)
|
||||
- PASS: `running`-job с ЖИВЫМ процессом в пределах его `agent_timeout` НЕ помечается
|
||||
зомби (ни по одному тику, ни в пределах `reaper_max_running_s`, если потолок
|
||||
больше таймаута).
|
||||
- FAIL: живой агент помечен `failed`/`queued` reaper'ом.
|
||||
|
||||
### AC-4 — корректный исход по результату
|
||||
- PASS: при `agent_runs.exit_code == 0` reaper доводит до успешного завершения без
|
||||
дублирования уже выполненного stage-advance (идемпотентно); при неуспехе и
|
||||
`attempts < max_attempts` → `queued`; при исчерпании → `failed` + Telegram.
|
||||
- FAIL: успешный исход помечен `failed`; либо дублируется stage-переход; либо
|
||||
исчерпанный бюджет молча зацикливается на `queued`.
|
||||
|
||||
### AC-5 — restart-safe совместимость
|
||||
- PASS: одновременная работа стартового `requeue_running_jobs()` и периодического
|
||||
reaper не приводит к двойной обработке одной строки (атомарный UPDATE с guard
|
||||
`status='running'`).
|
||||
- FAIL: одна строка обработана дважды / гонка приводит к рассинхрону статуса.
|
||||
|
||||
## B. Stale/dead merge-lease reclaim (Проблема B)
|
||||
|
||||
### AC-6 — реклейм lease мёртвого держателя
|
||||
- PASS: lease `.merge-lease-<repo>.json`, чей `pid` не существует, проактивно
|
||||
освобождается на старте И периодическим потоком (не дожидаясь TTL и не дожидаясь
|
||||
чужого `acquire`).
|
||||
- FAIL: lease мёртвого держателя остаётся до истечения `merge_lock_timeout_s` или
|
||||
до следующего чужого `acquire`.
|
||||
|
||||
### AC-7 — реклейм по TTL сохранён
|
||||
- PASS: lease старше `merge_lock_timeout_s` освобождается (существующий контракт не
|
||||
сломан), с `logger.warning`.
|
||||
- FAIL: просроченный lease не освобождается.
|
||||
|
||||
### AC-8 — не трогать живой lease
|
||||
- PASS: lease с ЖИВЫМ держателем (pid жив) и возрастом `< merge_lock_timeout_s` НЕ
|
||||
освобождается (защита легитимного merge).
|
||||
- FAIL: освобождён lease живого держателя → возможен параллельный конфликтный merge.
|
||||
|
||||
### AC-9 — условность и never-raise
|
||||
- PASS: реклейм реален только для `merge_gate_repos`/self-hosting; для прочих репо
|
||||
— no-op; любая ошибка реклейма логируется и не валит поток (never-raise).
|
||||
- FAIL: реклейм выполняется для не-self-hosting репо; либо ошибка пробрасывается
|
||||
наружу/роняет поток.
|
||||
|
||||
## C. Идемпотентная финализация merge (Проблема C)
|
||||
|
||||
### AC-10 — докатывание незавершённого merge
|
||||
- PASS: сценарий «rebase+re-test зелёные, merge не состоялся, процесс умер»
|
||||
восстанавливается автоматически (job → `queued` reaper'ом / reconciler доигрывает),
|
||||
и merge доводится без повторного ненужного прогона дорогих шагов.
|
||||
- FAIL: задача остаётся в полу-выполненном состоянии, требует ручного merge.
|
||||
|
||||
### AC-11 — идемпотентность при уже слитом PR
|
||||
- PASS: повторный вызов финализации при уже слитом PR — no-op (определяется по
|
||||
состоянию PR/`main`), без ошибки и без второго merge.
|
||||
- FAIL: второй merge / ошибка при уже слитом PR.
|
||||
|
||||
## D. Инварианты и безопасность self-hosting
|
||||
|
||||
### AC-12 — прод-контейнер не трогается
|
||||
- PASS: ни reaper, ни lease-reclaim не рестартят/не роняют прод-контейнер и не
|
||||
инициируют git-push в `main`.
|
||||
- FAIL: любая из новых веток кода рестартит self / пушит main.
|
||||
|
||||
### AC-13 — контракты неизменны
|
||||
- PASS: `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, сигнатуры/поведение `check_*`,
|
||||
БАГ-8 откат, exit-коды deploy-хука — без изменений; новых QG checks/стадий нет.
|
||||
- FAIL: затронут любой из перечисленных контрактов.
|
||||
|
||||
### AC-14 — kill-switches
|
||||
- PASS: `reaper_enabled=false` → reaper не работает (строго прежнее поведение);
|
||||
`lease_reclaim_enabled=false` → проактивный реклейм отключён (остаётся лишь
|
||||
прежний ленивый TTL-реклейм в `acquire`).
|
||||
- FAIL: флаг `false` не отключает соответствующий механизм.
|
||||
|
||||
## E. Наблюдаемость
|
||||
|
||||
### AC-15 — блок reaper в /queue
|
||||
- PASS: `GET /queue` содержит блок `reaper` (enabled, interval, last_run_ts,
|
||||
reaped_total, last_reaped, lease_reclaimed_total).
|
||||
- FAIL: блок отсутствует/не обновляется.
|
||||
|
||||
### AC-16 — логи и алерты
|
||||
- PASS: каждый reap и lease-reclaim → `logger.warning` с идентификаторами;
|
||||
reap→`failed` и lease-reclaim → Telegram.
|
||||
- FAIL: реап/реклейм происходят молча.
|
||||
|
||||
## F. Документация (gate reviewer)
|
||||
|
||||
### AC-17 — golden-source обновлён в этом же PR
|
||||
- PASS: обновлены `docs/architecture/README.md` (раздел про reaper + lease-reclaim),
|
||||
`CHANGELOG.md`, `.env.example` (новые `ORCH_*` флаги); заведён
|
||||
`06-adr/ADR-001-*.md`.
|
||||
- FAIL: код изменён, документация — нет (reviewer → REQUEST_CHANGES).
|
||||
|
||||
## G. Тесты
|
||||
|
||||
### AC-18 — регресс-тесты зелёные
|
||||
- PASS: новые unit/integration тесты (см. 04-test-plan.yaml) проходят; существующий
|
||||
`pytest tests/ -q` зелёный (нет регресса merge_gate / queue / reconciler).
|
||||
- FAIL: любой тест из плана красный или сломан существующий тест.
|
||||
196
docs/work-items/ORCH-065/04-test-plan.yaml
Normal file
196
docs/work-items/ORCH-065/04-test-plan.yaml
Normal file
@@ -0,0 +1,196 @@
|
||||
work_item: ORCH-065
|
||||
description: >
|
||||
Тест-план для фикса zombie jobs (job-reaper), залипшего merge-lease
|
||||
(проактивный реклейм dead/stale lease) и идемпотентной финализации merge.
|
||||
Все новые фоновые механизмы — never-raise, restart-safe, kill-switch.
|
||||
Модуль reaper и точные имена функций уточнит архитектор; в module указаны
|
||||
кандидатные пути (tests/test_job_reaper.py / tests/test_merge_lease_reclaim.py).
|
||||
|
||||
tests:
|
||||
# ---- A. Job-reaper ----
|
||||
- id: TC-01
|
||||
type: unit
|
||||
description: >
|
||||
Reaper переводит running-job с мёртвым исполнителем в корректный статус
|
||||
без рестарта процесса (pid не существует / exit_code записан, job всё ещё
|
||||
running). Покрывает AC-1.
|
||||
module: tests/test_job_reaper.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-02
|
||||
type: unit
|
||||
description: >
|
||||
Анти-ложноположительность: running-job с ЖИВЫМ процессом в пределах
|
||||
agent_timeout НЕ реапится (ни по одному тику, ни в пределах reaper_max_running_s).
|
||||
Покрывает AC-3.
|
||||
module: tests/test_job_reaper.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-03
|
||||
type: unit
|
||||
description: >
|
||||
Устойчивость: job помечается зомби только после reaper_dead_ticks
|
||||
последовательных тиков смерти pid (>=2), а не на первом тике. Покрывает FR-1.3.
|
||||
module: tests/test_job_reaper.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-04
|
||||
type: unit
|
||||
description: >
|
||||
Backstop по потолку: job, висящий running дольше reaper_max_running_s,
|
||||
реапится даже если liveness определить нельзя. Покрывает FR-1.1/AC-1.
|
||||
module: tests/test_job_reaper.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-05
|
||||
type: unit
|
||||
description: >
|
||||
Корректный исход: exit_code==0 -> успешное завершение без дублирования
|
||||
stage-advance; неуспех при attempts<max -> queued; исчерпан бюджет -> failed
|
||||
+ Telegram. Покрывает AC-4.
|
||||
module: tests/test_job_reaper.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-06
|
||||
type: unit
|
||||
description: >
|
||||
Атомарность reap-UPDATE с guard status='running': параллельная обработка
|
||||
одной строки (стартовый requeue_running_jobs + reaper) не приводит к двойному
|
||||
reap. Покрывает AC-5.
|
||||
module: tests/test_job_reaper.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-07
|
||||
type: unit
|
||||
description: >
|
||||
Kill-switch reaper_enabled=false -> reaper не трогает ни одной строки
|
||||
(строго прежнее поведение). Покрывает AC-14.
|
||||
module: tests/test_job_reaper.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-08
|
||||
type: unit
|
||||
description: >
|
||||
never-raise: ошибка БД/ОС внутри одного тика reaper не пробрасывается и не
|
||||
останавливает поток (изоляция per-job, образец reconciler). Покрывает AC-9.
|
||||
module: tests/test_job_reaper.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-09
|
||||
type: integration
|
||||
description: >
|
||||
Очередь разблокируется: после reap зомби-строки при max_concurrency=1
|
||||
следующий queued-job успешно claim'ится (claim_next_job + count_running_jobs).
|
||||
Покрывает AC-2.
|
||||
module: tests/test_queue.py
|
||||
expected: PASS
|
||||
|
||||
# ---- B. Stale/dead merge-lease reclaim ----
|
||||
- id: TC-10
|
||||
type: unit
|
||||
description: >
|
||||
Реклейм lease с мёртвым pid (os.kill(pid,0) -> ProcessLookupError)
|
||||
проактивно, не дожидаясь TTL и чужого acquire. Покрывает AC-6.
|
||||
module: tests/test_merge_lease_reclaim.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-11
|
||||
type: unit
|
||||
description: >
|
||||
Реклейм по TTL (age >= merge_lock_timeout_s) сохранён, с logger.warning.
|
||||
Покрывает AC-7. (расширяет существующий stale-lease сценарий merge_gate.)
|
||||
module: tests/test_merge_lease_reclaim.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-12
|
||||
type: unit
|
||||
description: >
|
||||
Живой lease (pid жив, age < TTL) НЕ освобождается — защита легитимного merge.
|
||||
Покрывает AC-8.
|
||||
module: tests/test_merge_lease_reclaim.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-13
|
||||
type: unit
|
||||
description: >
|
||||
Условность: реклейм реален только для merge_gate_repos/self-hosting; для
|
||||
прочих репо no-op. Покрывает AC-9.
|
||||
module: tests/test_merge_lease_reclaim.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-14
|
||||
type: unit
|
||||
description: >
|
||||
never-raise: ошибка чтения/удаления lease-файла не валит реклейм-поток.
|
||||
Покрывает AC-9.
|
||||
module: tests/test_merge_lease_reclaim.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-15
|
||||
type: unit
|
||||
description: >
|
||||
Kill-switch lease_reclaim_enabled=false -> проактивный реклейм отключён,
|
||||
остаётся лишь прежний ленивый TTL-реклейм в acquire_merge_lease.
|
||||
Покрывает AC-14.
|
||||
module: tests/test_merge_lease_reclaim.py
|
||||
expected: PASS
|
||||
|
||||
# ---- C. Идемпотентная финализация merge ----
|
||||
- id: TC-16
|
||||
type: unit
|
||||
description: >
|
||||
Идемпотентность финализации: повторный вызов при уже слитом PR / уже
|
||||
актуальном main (branch_is_behind_main == False) — no-op, без ошибки и без
|
||||
второго merge. Покрывает AC-11.
|
||||
module: tests/test_merge_gate.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-17
|
||||
type: integration
|
||||
description: >
|
||||
Восстановление: сценарий "rebase+re-test зелёные, merge не состоялся,
|
||||
процесс умер" -> job доводится до queued reaper'ом и merge докатывается
|
||||
штатным путём без повторного дорогого re-test, когда безопасно. Покрывает AC-10.
|
||||
module: tests/test_merge_gate_race.py
|
||||
expected: PASS
|
||||
|
||||
# ---- D/E. Инварианты и наблюдаемость ----
|
||||
- id: TC-18
|
||||
type: integration
|
||||
description: >
|
||||
GET /queue содержит блок reaper (enabled, interval, last_run_ts,
|
||||
reaped_total, last_reaped, lease_reclaimed_total). Покрывает AC-15.
|
||||
module: tests/test_queue.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-19
|
||||
type: unit
|
||||
description: >
|
||||
Контракты неизменны: STAGE_TRANSITIONS и реестр QG_CHECKS не получили новых
|
||||
стадий/чеков; check_branch_mergeable сигнатурно не изменён. Покрывает AC-13.
|
||||
module: tests/test_config.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-20
|
||||
type: unit
|
||||
description: >
|
||||
Новые настройки reaper_*/lease_reclaim_* присутствуют в config с дефолтами и
|
||||
читаются из ORCH_* env. Покрывает §5 ТЗ / AC-14.
|
||||
module: tests/test_config.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-21
|
||||
type: unit
|
||||
description: >
|
||||
Стартовый реклейм stale/dead lease вызывается в lifespan рядом с
|
||||
requeue_running_jobs (smoke на регистрацию старт/стоп reaper-потока).
|
||||
Покрывает FR-2.1 / AC-6.
|
||||
module: tests/test_job_reaper.py
|
||||
expected: PASS
|
||||
|
||||
regression:
|
||||
- command: pytest tests/ -q
|
||||
expected: PASS
|
||||
note: >
|
||||
Полный прогон не должен ломать существующие тесты merge_gate / queue /
|
||||
reconciler / deploy.
|
||||
@@ -0,0 +1,275 @@
|
||||
# ADR-001 (ORCH-065): Job-reaper + проактивный реклейм merge-lease + идемпотентная финализация merge
|
||||
|
||||
## Статус
|
||||
Accepted — 2026-06-07
|
||||
|
||||
Связь со сквозным ADR: [docs/architecture/adr/adr-0011-job-reaper-lease-reclaim.md](../../../architecture/adr/adr-0011-job-reaper-lease-reclaim.md).
|
||||
|
||||
## Контекст
|
||||
|
||||
Оркестратор — единый инстанс с **общей БД и общей очередью** (`jobs`,
|
||||
`max_concurrency=1` для self-hosting). BRD/ТЗ фиксируют три связанных класса
|
||||
отказов «процесс/поток умер, а состояние осталось захваченным навсегда»:
|
||||
|
||||
- **A — zombie jobs.** Статус job (`done`/`queued`/`failed`) выставляется ТОЛЬКО
|
||||
в `launcher._monitor_agent` → `_finalize_job` внутри того же процесса. Смерть
|
||||
monitor-потока/процесса между `proc.wait()` и `_finalize_job` (краш, OOM,
|
||||
self-restart во время deploy) оставляет строку `jobs` навсегда `running`.
|
||||
Единственная защита — `requeue_running_jobs()`, срабатывает ТОЛЬКО на старте
|
||||
процесса. Зомби без рестарта не реанимируется никогда. При `max_concurrency=1`
|
||||
одна зомби-строка блокирует claim всех job (`count_running_jobs() >=
|
||||
max_concurrency`) → встаёт конвейер ВСЕХ проектов. Доказано: jobs 236/239/242/254
|
||||
(07.06).
|
||||
- **B — залипший merge-lease.** Файловый lease `.merge-lease-<repo>.json`
|
||||
(ORCH-043) реклеймится **лениво и только по возрасту** (`age >=
|
||||
merge_lock_timeout_s`) и **только** в момент `acquire_merge_lease` другой
|
||||
задачей. Liveness держателя по pid не проверяется, хотя pid в lease пишется.
|
||||
Смерть с зажатым lease блокирует чужие merge до истечения TTL.
|
||||
- **C — неидемпотентная финализация merge.** Если rebase+re-test зелёные, но
|
||||
процесс умер до фактического merge PR — повторного докатывания нет; дорогая
|
||||
работа (rebase+re-test) сделана, а задача висит.
|
||||
|
||||
Факты кода, на которых строится решение:
|
||||
- `_spawn` (launcher.py:401) создаёт `subprocess.Popen(["bash","-c",cmd])`;
|
||||
`proc.pid` — pid агентского процесса, дочернего к процессу оркестратора в ОДНОМ
|
||||
pid-namespace контейнера. Сейчас `jobs.pid` НЕ хранится.
|
||||
- `_monitor_agent` (launcher.py:541) порядок: `proc.wait()` → запись
|
||||
`agent_runs.finished_at/exit_code` → git commit/push (+PR) → БАГ-8 deployer
|
||||
rollback → usage-комменты → `_try_advance_stage` (exit0, gate-driven advance)
|
||||
→ `_finalize_job` (драйв статуса job по контракту attempts/transient).
|
||||
- `claim_next_job` (db.py:454) — атомарный claim через `UPDATE ... WHERE id=? AND
|
||||
status='queued'` + `rowcount` (образец атомарности).
|
||||
- `reconciler.py` — образец фонового daemon-потока (never-raise, `_stop`-Event,
|
||||
старт/стоп в `main.lifespan`, снимок в `/queue`, kill-switch).
|
||||
- `merge_gate.py`: lease пишет `pid=os.getpid()` (pid процесса оркестратора, НЕ
|
||||
агента), `acquired_at`; `release_merge_lease` уже holder-aware (по `branch`) и
|
||||
идемпотентен; `acquire_merge_lease` идемпотентен для «held by self» (по branch).
|
||||
- `is_self_hosting_repo` / `merge_gate_repos` — образец условности (ORCH-35/43).
|
||||
|
||||
## Решение
|
||||
|
||||
### Р-1. Job-reaper — отдельный daemon-поток `src/job_reaper.py`
|
||||
|
||||
Reaper — **новый модуль и отдельный daemon-поток** (НЕ расширение reconciler).
|
||||
Обоснование: reconciler работает на уровне **stage-transition** (источник истины —
|
||||
гейт/Plane); reaper работает на уровне **jobs/agent_runs** (источник истины —
|
||||
liveness процесса). Это разные never-raise-домены и разные kill-switch'и; слияние
|
||||
в один тик смешало бы ответственности. Reaper копирует проверенный каркас
|
||||
`Reconciler`: `threading.Thread(daemon=True)` + `threading.Event`, старт/стоп в
|
||||
`main.lifespan`, снимок в `/queue`, per-job изоляция исключений.
|
||||
|
||||
**Liveness — трёхуровневая (defense in depth):**
|
||||
|
||||
1. **Tier-1 (liveness, основной): мёртвый pid.** Добавляем колонку `jobs.pid`
|
||||
(см. Р-4). В `_spawn` рядом с `run_id`/`started_at` пишем `proc.pid`. Reaper:
|
||||
`pid_alive(pid)` = `os.kill(pid, 0)` с обработкой `ProcessLookupError` (мёртв)
|
||||
/ `PermissionError` (жив, чужой) — единственный сигнал, ловящий «monitor умер
|
||||
ДО записи `finished_at`».
|
||||
2. **Tier-2 (completion race): exit_code записан, job ещё `running`.** Окно
|
||||
**неоднозначно**: это И «monitor умер между записью exit_code и
|
||||
`_finalize_job`», И «живой monitor ещё финализирует» — `_monitor_agent`
|
||||
пишет `exit_code` ПЕРВЫМ, затем git commit/push (+PR), БАГ-8-проверку и
|
||||
сетевые usage-комментарии в Plane (секунды-десятки секунд), и лишь потом
|
||||
`_try_advance_stage` → `_finalize_job`. pid агента к этому моменту уже мёртв в
|
||||
ОБОИХ случаях, поэтому по pid их не различить. **Анти-ложноположительность
|
||||
Tier-2 (FR-1.3, AC-3): finalization-grace.** Job реапится по Tier-2 только
|
||||
когда `exit_code` записан не меньше `reaper_finalize_grace_s` назад (потолок
|
||||
заведомо > максимального окна финализации). В пределах grace строка не
|
||||
трогается (живой финализирующий monitor НИКОГДА не реапится; нет дубль-advance
|
||||
/ дубль-enqueue). После grace monitor заведомо мёртв → исход **известен**.
|
||||
3. **Tier-3 (backstop по потолку):** job висит `running` дольше
|
||||
`reaper_max_running_s` (заведомо > max `agent_timeout`+grace). Реап даже когда
|
||||
liveness определить нельзя (pid переиспользован/неизвестен).
|
||||
|
||||
**Анти-ложноположительность (FR-1.3, AC-3):** по Tier-1 job реапится только после
|
||||
`reaper_dead_ticks` (≥2) ПОДРЯД тиков мёртвого pid — in-memory streak-счётчик по
|
||||
`job_id` (best-effort, сбрасывается на рестарте — но рестарт покрыт стартовым
|
||||
`requeue_running_jobs`). Tier-3 — одношаговый (порог времени, streak не нужен).
|
||||
Живой агент в пределах своего `agent_timeout` НЕ реапится никогда (pid жив + не
|
||||
превышен потолок).
|
||||
|
||||
**Действие при подтверждённой смерти (FR-1.2, AC-4) — переиспользование
|
||||
существующих контрактов, без дублирования:**
|
||||
|
||||
- **Атомарный reap-claim.** Перед любым действием с побочными эффектами reaper
|
||||
атомарно «застолбляет» строку тем же приёмом, что `claim_next_job`: терминальный
|
||||
flip несёт guard `WHERE id=? AND status='running'` и проверяет `rowcount`. При
|
||||
гонке (поздно доехавший monitor, стартовый `requeue_running_jobs`) проигравший
|
||||
видит `rowcount==0` и НЕ обрабатывает строку повторно (AC-5).
|
||||
- **Исход известен (Tier-2, exit_code в `agent_runs`, grace прошёл):**
|
||||
- `exit==0`: **claim-BEFORE-act, gate-driven idempotent advance.** Порядок
|
||||
критичен (см. «Атомарный reap-claim» выше): атомарный claim ОБЯЗАН
|
||||
предшествовать любому `advance_stage`/`enqueue_job`. Поскольку claim
|
||||
переводит строку ИЗ `running`, прогнать advance ДО claim, чтобы узнать цвет
|
||||
гейта, нельзя — поэтому канонический QG оценивается **read-only, без
|
||||
побочных эффектов** (тот же `_run_qg`, что у reconciler) ПЕРЕД claim:
|
||||
- стадия уже продвинута мимо этого агента → атомарный `done` без advance
|
||||
(идемпотентная уборка);
|
||||
- гейт зелёный → атомарный claim `done` ПЕРВЫМ, и только победитель claim
|
||||
выполняет `_try_advance_stage` (advance + `enqueue_job` следующей стадии)
|
||||
РОВНО один раз; проигравший claim (поздний monitor / стартовый
|
||||
`requeue_running_jobs`) НЕ делает побочных эффектов (нет дубль-advance /
|
||||
дубль-enqueue);
|
||||
- гейт красный (monitor умер ДО git-push, артефакта нет) → НЕ выдумываем
|
||||
`done`, уходим в ветку «исход неуспешен» ниже.
|
||||
**Источник истины — гейт, не «exit0».**
|
||||
- `exit!=0`: ровно существующий контракт `_finalize_job` (классификация
|
||||
transient/permanent, `attempts<max` → `queued`, иначе `failed`+Telegram).
|
||||
- **Исход неизвестен (Tier-1 мёртвый pid без exit_code, или Tier-3 backstop):**
|
||||
не выдумываем `exit0`. Если `attempts < max_attempts` → `queued` (как
|
||||
`requeue_running_jobs`); если бюджет исчерпан → `failed` + Telegram-алерт.
|
||||
|
||||
**Restart-safe (FR-1.5, AC-5):** reaper стартует в `lifespan` ПОСЛЕ
|
||||
`requeue_running_jobs()`, поэтому при рестарте сначала отрабатывает стартовый
|
||||
requeue, а периодический reaper лишь добивает то, что возникнет позже; атомарный
|
||||
guard `status='running'` исключает двойную обработку.
|
||||
|
||||
### Р-2. Проактивный реклейм stale/dead merge-lease — функции в `merge_gate.py`
|
||||
|
||||
Логика lease живёт в одном месте (`merge_gate.py`). Добавляем:
|
||||
- `pid_alive(pid) -> bool` (never-raise; ошибка/`PermissionError` → считаем
|
||||
«жив», т.е. консервативно НЕ реклеймим — безопаснее не трогать).
|
||||
- `reclaim_stale_lease(repo) -> bool` — для репо из области (см. ниже): прочитать
|
||||
lease; освободить (`release_merge_lease(repo, branch=holder_branch)` —
|
||||
holder-aware), если держатель **мёртв** (`pid` из lease не жив) ИЛИ **просрочен**
|
||||
(`age >= merge_lock_timeout_s`). Живой держатель в пределах TTL — НЕ трогать
|
||||
(AC-8, защита легитимного merge). Каждый реклейм → `logger.warning` +
|
||||
Telegram.
|
||||
|
||||
**Точки вызова (FR-2.1):**
|
||||
- на старте — в `lifespan` рядом с `requeue_running_jobs()`;
|
||||
- периодически — из тика reaper (один общий фоновый поток на оба механизма A и B).
|
||||
|
||||
**Условность (FR-2.4, AC-9):** реально только для `merge_gate_repos`/self-hosting
|
||||
(тот же предикат, что merge-gate); прочие репо — no-op. Kill-switch
|
||||
`lease_reclaim_enabled` (=false → остаётся лишь прежний ленивый TTL-реклейм в
|
||||
`acquire_merge_lease`). Контракт **never-raise**: ошибка реклейма логируется и не
|
||||
валит поток.
|
||||
|
||||
**pid-семантика lease:** lease пишет pid процесса ОРКЕСТРАТОРА (`os.getpid()`).
|
||||
После рестарта контейнера старый pid мёртв → детектируется. Риск pid-reuse
|
||||
(контейнер мог переиспользовать номер pid) закрыт тем, что реклейм срабатывает по
|
||||
**ИЛИ** (pid мёртв **ИЛИ** TTL истёк): даже при ложном «жив» TTL добьёт lease
|
||||
(контракт ORCH-043 сохранён). См. 10-tech-risks.
|
||||
|
||||
### Р-3. Идемпотентная финализация merge (Проблема C) — re-drive + guard, без новой merge-логики
|
||||
|
||||
Per FR-3.3 — НЕ создаём дублирующую merge-логику. Восстановление обеспечивается
|
||||
**штатными путями**:
|
||||
- reaper доводит зомби-job до `queued` → стадия `deploy-staging`/`deploy`
|
||||
переисполняется и снова проходит `check_branch_mergeable` (merge-gate), ЛИБО
|
||||
reconciler доигрывает переход по зелёному гейту;
|
||||
- дорогие шаги не повторяются «вхолостую»: `branch_is_behind_main == False` → этап
|
||||
rebase+re-test пропускается (ветка уже догнана);
|
||||
- lease при повторе берётся заново — `acquire_merge_lease` уже идемпотентен для
|
||||
«held by self» по branch (FR-3.4).
|
||||
|
||||
**Идемпотентность у самого merge (FR-3.2, AC-11):** добавляем детерминированный
|
||||
never-raise guard `pr_already_merged(repo, branch) -> bool` (переиспользует
|
||||
существующий Gitea-клиент; запрос состояния PR). Путь слияния (deployer/merge)
|
||||
консультируется с этим guard ПЕРЕД повторным merge: PR уже слит → no-op (без
|
||||
второго merge и без ошибки). Это единственная новая «merge-related» функция — она
|
||||
не сливает, а лишь читает состояние, поэтому не нарушает «no duplicate merge
|
||||
logic».
|
||||
|
||||
### Р-4. Изменение схемы БД — `jobs.pid INTEGER` (lightweight migration)
|
||||
|
||||
Колонка добавляется идемпотентно через существующий `_ensure_column(conn, "jobs",
|
||||
"pid", "INTEGER")` в `init_db` (паттерн уже применяется к `jobs.transient_attempts`
|
||||
/ `jobs.available_at` — безопасно на live prod DB). `pid` проставляется в `_spawn`
|
||||
рядом с `run_id`/`started_at`. **Альтернатива без миграции отвергнута** (см.
|
||||
Альтернативы): только по `agent_runs.finished_at/exit_code` нельзя поймать
|
||||
зомби, у которого monitor умер ДО записи exit_code — а это и есть основной класс
|
||||
инцидента. `STAGE_TRANSITIONS`, `QG_CHECKS`, схема `agent_runs`, файл-схема lease —
|
||||
без изменений.
|
||||
|
||||
### Р-5. Конфигурация (`src/config.py`, env `ORCH_*`)
|
||||
|
||||
| Настройка | Назначение | Дефолт |
|
||||
|-----------|-----------|--------|
|
||||
| `reaper_enabled` | глобальный kill-switch job-reaper | `True` |
|
||||
| `reaper_interval_s` | период сканирования | `60` |
|
||||
| `reaper_dead_ticks` | подряд тиков мёртвого pid перед реапом (Tier-1) | `2` |
|
||||
| `reaper_max_running_s` | потолок `running` (Tier-3 backstop), > max agent_timeout+grace | `3600` |
|
||||
| `reaper_finalize_grace_s` | Tier-2 grace: сколько `exit_code` должен быть записан до реапа (> max окна финализации) | `300` |
|
||||
| `lease_reclaim_enabled` | kill-switch проактивного реклейма lease | `True` |
|
||||
| (reuse) `merge_lock_timeout_s` | TTL lease | `300` |
|
||||
| (reuse) `merge_gate_repos` | область применения lease-reclaim | как есть |
|
||||
|
||||
`false` → строго прежнее поведение (AC-14).
|
||||
|
||||
### Р-6. Наблюдаемость (`GET /queue`)
|
||||
|
||||
Блок `reaper` (образец `reconcile`/`post_deploy`): `enabled`, `interval`,
|
||||
`last_run_ts`, `reaped_total`, `last_reaped` (`{job_id, agent, outcome}`),
|
||||
`lease_reclaimed_total`. Каждый reap и lease-reclaim → `logger.warning` с
|
||||
идентификаторами (`job_id`, `run_id`, `pid`, `repo`, `branch`). reap→`failed` и
|
||||
lease-reclaim → Telegram (AC-16).
|
||||
|
||||
### Р-7. Lifespan (`src/main.py`)
|
||||
|
||||
Старт (после существующего `requeue_running_jobs()`):
|
||||
```
|
||||
init_db() # + _ensure_column(jobs, pid)
|
||||
... orphan-recovery (M-1) ...
|
||||
requeue_running_jobs()
|
||||
+ startup lease-reclaim # reclaim_stale_lease для merge_gate_repos
|
||||
worker.start()
|
||||
reconciler.start()
|
||||
+ reaper.start() # НОВЫЙ daemon-поток (A + периодический B)
|
||||
```
|
||||
Стоп (reverse): `reaper.stop()` → `reconciler.stop()` → `worker.stop()`.
|
||||
|
||||
## Альтернативы
|
||||
|
||||
- **Reaper как часть reconciler** — отвергнуто: смешивает stage-уровень и
|
||||
jobs-уровень, два разных kill-switch'а в одном тике, хуже изоляция отказов.
|
||||
- **Без `jobs.pid`, только эвристика `agent_runs` + потолок** — отвергнуто как
|
||||
основной механизм: не ловит зомби, чей monitor умер ДО записи `exit_code`
|
||||
(главный класс инцидента). Эвристика оставлена как Tier-2/Tier-3 поверх pid.
|
||||
- **БД-lock вместо файлового lease / внешний брокер очередей** — вне объёма
|
||||
(BRD §4), несоразмерно для single-node SQLite.
|
||||
- **Реаниматор фабрикует `exit0` и форсит `done`** — отвергнуто: ложный `done`
|
||||
без реальной работы (если git-push не случился). Выбран gate-driven advance:
|
||||
источник истины — канонический QG, не предположение об успехе.
|
||||
- **Новый статус `reaping` в enum `jobs.status`** — отвергнуто: меняет контракт
|
||||
статусов; атомарного guard `WHERE status='running'` достаточно.
|
||||
|
||||
## Последствия
|
||||
|
||||
**Плюсы:**
|
||||
- Зомби-job самовосстанавливается БЕЗ рестарта процесса → очередь не встаёт
|
||||
(групповой риск снят для всех проектов общего инстанса).
|
||||
- Залипший lease освобождается проактивно (старт + период), не дожидаясь TTL и
|
||||
чужого acquire.
|
||||
- Незавершённый merge докатывается штатным путём, идемпотентно; ручные шаги
|
||||
оператора устранены → снят технический блокер ORCH-54.
|
||||
- Контракты неизменны (`STAGE_TRANSITIONS`, `QG_CHECKS`, `check_*`, БАГ-8,
|
||||
exit-коды хука); один новый столбец через проверенный idempotent-паттерн.
|
||||
|
||||
**Минусы / ограничения:**
|
||||
- pid-liveness валиден в предположении ОДНОГО pid-namespace (агент — дочерний
|
||||
процесс оркестратора в том же контейнере). Multi-container/namespaced pid →
|
||||
pid-liveness ненадёжен; закрыто backstop'ом по времени и TTL. См. 10-tech-risks.
|
||||
- streak-счётчик in-memory best-effort: после рестарта он сбрасывается, но
|
||||
стартовый `requeue_running_jobs` покрывает рестарт-сценарий.
|
||||
- Tier-3 backstop консервативен (потолок > max timeout); очень долгий легитимный
|
||||
агент (близко к потолку) теоретически может быть реапнут — потолок выбран с
|
||||
большим запасом, чтобы этого не случалось (AC-3).
|
||||
|
||||
## Инварианты (НЕ нарушать)
|
||||
- Reaper/lease-reclaim НЕ рестартят/не роняют прод-контейнер `orchestrator` и НЕ
|
||||
инициируют git-push в `main` (AC-12). Реклейм lease — только удаление
|
||||
файла-lease, без git-операций.
|
||||
- `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, сигнатуры/поведение `check_*` (в т.ч.
|
||||
`check_branch_mergeable`), БАГ-8 откат, exit-коды deploy-хука — без изменений;
|
||||
новых QG checks/стадий нет (AC-13).
|
||||
- never-raise на единицу фоновой работы; идемпотентность (атомарный guard +
|
||||
gate-driven advance); restart-safe; тишина при отсутствии аномалий.
|
||||
- Анти-ложноположительность (FR-1.3): живой долгий агент не реапится.
|
||||
|
||||
## Связи
|
||||
- Базируется на: ORCH-1 (очередь, adr-0002), ORCH-043 (merge-gate, adr-0006),
|
||||
ORCH-053 (reconciler-паттерн, adr-0007), ORCH-36 (self-deploy, adr-0007).
|
||||
- Сквозной ADR: adr-0011.
|
||||
- Разблокирует: ORCH-54 (полностью автономный self-deploy).
|
||||
42
docs/work-items/ORCH-065/07-infra-requirements.md
Normal file
42
docs/work-items/ORCH-065/07-infra-requirements.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# 07 — Требования к инфраструктуре (ORCH-065)
|
||||
|
||||
## Топология
|
||||
**Без изменений.** Новых контейнеров, портов, сетевых сервисов, внешних
|
||||
зависимостей нет. Job-reaper — ещё один фоновый daemon-поток ВНУТРИ существующего
|
||||
процесса оркестратора (как `queue_worker` и `reconciler`), стартует/останавливается
|
||||
в `main.lifespan`. Деплой/рестарт прод-контейнера в рамках задачи НЕ требуется и
|
||||
ЗАПРЕЩЁН (self-hosting safety) — выкатка через штатный `deploy-staging → deploy`.
|
||||
|
||||
## Допущение pid-namespace (важно для liveness-детекции)
|
||||
- Агент запускается как `subprocess.Popen(["bash","-c",cmd])` — **дочерний
|
||||
процесс оркестратора в ТОМ ЖЕ pid-namespace** (один контейнер). Значит
|
||||
`os.kill(jobs.pid, 0)` корректно отражает liveness агента, пока жив сам
|
||||
оркестратор. Это инвариант текущей упаковки (один контейнер на инстанс).
|
||||
- Lease пишет `pid = os.getpid()` — pid ПРОЦЕССА ОРКЕСТРАТОРА. После рестарта
|
||||
контейнера старый pid мёртв → детектируется. Риск переиспользования номера pid
|
||||
новым процессом закрыт условием «pid мёртв **ИЛИ** TTL истёк»: TTL добивает
|
||||
lease в любом случае (контракт ORCH-043 сохранён).
|
||||
- **Если в будущем агенты переедут в отдельные контейнеры/namespace** — Tier-1
|
||||
pid-liveness станет ненадёжной; тогда полагаемся на Tier-2 (exit_code) и Tier-3
|
||||
(потолок `reaper_max_running_s`). Зафиксировано в 10-tech-risks.
|
||||
|
||||
## Поведение при self-restart (ORCH-36 executable self-deploy)
|
||||
Self-restart прод-контейнера во время `deploy` — ровно тот сценарий, что плодит
|
||||
зомби: monitor-поток умирает вместе со старым контейнером. После рестарта:
|
||||
1. стартовый `requeue_running_jobs()` + стартовый `reclaim_stale_lease` чистят
|
||||
состояние, оставшееся от убитого процесса;
|
||||
2. периодический reaper добивает то, что возникнет позже без рестарта.
|
||||
Reaper/lease-reclaim сами НИКОГДА не рестартят и не роняют прод-контейнер и не
|
||||
делают git-push в `main` (AC-12).
|
||||
|
||||
## Эксплуатационные ручки (env, хост `.env`/`.env.staging`)
|
||||
`ORCH_REAPER_ENABLED`, `ORCH_REAPER_INTERVAL_S`, `ORCH_REAPER_DEAD_TICKS`,
|
||||
`ORCH_REAPER_MAX_RUNNING_S`, `ORCH_LEASE_RECLAIM_ENABLED`; переиспользуются
|
||||
`ORCH_MERGE_LOCK_TIMEOUT_S`, `ORCH_MERGE_GATE_REPOS`. Все флаги документируются в
|
||||
`.env.example` (developer-стадия). Полное отключение (`false`) → строго прежнее
|
||||
поведение.
|
||||
|
||||
## Документация эксплуатации
|
||||
`docs/operations/INFRA.md` — добавить (best-effort, developer/PR) короткое
|
||||
упоминание поведения reaper/lease-reclaim при self-restart. Топологическая карта
|
||||
INFRA.md не меняется.
|
||||
29
docs/work-items/ORCH-065/08-data-requirements.md
Normal file
29
docs/work-items/ORCH-065/08-data-requirements.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# 08 — Требования к данным (ORCH-065)
|
||||
|
||||
## Изменение схемы: `jobs.pid`
|
||||
|
||||
| Поле | Значение |
|
||||
|------|----------|
|
||||
| Таблица | `jobs` |
|
||||
| Колонка | `pid` |
|
||||
| Тип | `INTEGER` (nullable, без DEFAULT) |
|
||||
| Назначение | pid агентского процесса (`subprocess.Popen.pid` из `launcher._spawn`) для liveness-детекции зомби job-reaper'ом (Tier-1) |
|
||||
| Механизм миграции | `_ensure_column(conn, "jobs", "pid", "INTEGER")` в `db.init_db` — идемпотентно, no-op если колонка уже есть |
|
||||
| Безопасность на live prod DB | ДА. Тот же паттерn уже применён к `jobs.transient_attempts`, `jobs.available_at`, `events.delivery_id`, `agent_runs.*`. `ALTER TABLE ADD COLUMN` в SQLite — мгновенная метаданная-операция, не блокирует и не переписывает строки |
|
||||
| Заполнение | в `_spawn` рядом с существующим `UPDATE jobs SET run_id=?, started_at=datetime('now') WHERE id=?` добавить `pid=?` (`proc.pid`). Старые строки остаются `pid IS NULL` → для них Tier-1 неприменим, работают Tier-2/Tier-3 |
|
||||
|
||||
## Что НЕ меняется
|
||||
- `STAGE_TRANSITIONS`, реестр `QG_CHECKS` — без изменений (это контракты).
|
||||
- Схема `agent_runs` — без изменений (`finished_at`/`exit_code` уже есть — основа Tier-2).
|
||||
- Файл-схема merge-lease `.merge-lease-<repo>.json` — без изменений (`pid`,
|
||||
`acquired_at`, `branch`, `work_item_id`, `task_id` уже пишутся
|
||||
`acquire_merge_lease`).
|
||||
- `jobs.status` enum (`queued|running|done|failed`) — без изменений; новый статус
|
||||
`reaping` НЕ вводится (атомарного guard `WHERE status='running'` достаточно).
|
||||
|
||||
## Совместимость / откат
|
||||
- Откат миграции не требуется: лишняя nullable-колонка безвредна при
|
||||
`reaper_enabled=false`.
|
||||
- `pid IS NULL` (строки до миграции, или если запись pid не успела) → reaper не
|
||||
делает Tier-1, опирается на Tier-2 (exit_code) и Tier-3 (потолок). Поведение
|
||||
деградирует gracefully, ложноположительных реапов не возникает.
|
||||
22
docs/work-items/ORCH-065/10-tech-risks.md
Normal file
22
docs/work-items/ORCH-065/10-tech-risks.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# 10 — Технические риски (ORCH-065)
|
||||
|
||||
| # | Риск | Вероятн. | Влияние | Митигация |
|
||||
|---|------|----------|---------|-----------|
|
||||
| R-1 | **Ложноположительный реап живого долгого агента** (AC-3). Reaper помечает зомби работающий агент → потеря работы, дубль-запуск. | Сред. | Высокое | Tier-1 требует `reaper_dead_ticks`(≥2) подряд тиков мёртвого pid; живой pid = `os.kill(pid,0)` без `ProcessLookupError`. Tier-3 потолок `reaper_max_running_s` выбирается заведомо > max `agent_timeout`+grace. Юнит-тест TC-02/TC-03. |
|
||||
| R-2 | **Ложный `done` без выполненной работы.** Reaper при exit0-зомби помечает job done, хотя git-push/advance не случились (monitor умер до них). | Сред. | Высокое | Реап exit0 НЕ форсит done напрямую — идёт через **gate-driven** `_try_advance_stage`: канонический QG проверяет наличие артефакта/PR; нет артефакта → красный гейт → НЕ advance → ветка «исход неуспешен» (requeue). Источник истины — гейт, не «exit0». |
|
||||
| R-3 | **pid-reuse / namespace.** Номер pid переиспользован новым процессом → ложное «жив» (lease не реклеймится; зомби-job не реапится по Tier-1). | Низк. | Сред. | Lease: условие «pid мёртв **ИЛИ** TTL истёк» — TTL добивает в любом случае. Job-reaper: Tier-3 backstop по времени ловит то, что Tier-1 пропустил. Допущение «один pid-namespace» зафиксировано в 07-infra. |
|
||||
| R-4 | **Гонка reaper vs поздно доехавший monitor / стартовый `requeue_running_jobs`** → двойная обработка строки. | Сред. | Сред. | Атомарный reap-claim `UPDATE ... WHERE id=? AND status='running'` + проверка `rowcount` (образец `claim_next_job`). Reaper стартует ПОСЛЕ `requeue_running_jobs` в lifespan. Юнит-тест TC-06. |
|
||||
| R-5 | **Реклейм живого lease** → параллельный конфликтный merge, риск красного `main` self-hosting. | Низк. | Высокое | `reclaim_stale_lease` освобождает ТОЛЬКО при «держатель мёртв ИЛИ TTL истёк»; живой держатель в пределах TTL не трогается. holder-aware `release_merge_lease(repo, branch)`. Юнит-тест TC-12. |
|
||||
| R-6 | **Реклейм инициирует git-операцию / трогает прод-контейнер** (нарушение self-hosting safety, AC-12). | Низк. | Высокое | Реклейм = только удаление файла-lease (`os.remove`), без git. Reaper не вызывает деплой-хук/рестарт. Явный инвариант в ADR + тест/ревью. |
|
||||
| R-7 | **Идемпотентность merge не достигнута**: повторный проход стадии делает второй merge уже слитого PR. | Сред. | Сред. | never-raise guard `pr_already_merged(repo,branch)` (читает состояние PR) консультируется перед merge → уже слит = no-op. `branch_is_behind_main==False` пропускает rebase+re-test. Юнит-тест TC-16, интеграция TC-17. |
|
||||
| R-8 | **streak-счётчик in-memory теряется при рестарте** → задержка реапа или сброс прогресса. | Низк. | Низкое | Рестарт-сценарий покрыт стартовым `requeue_running_jobs` (мгновенно чистит running). Периодический reaper нужен лишь для зомби БЕЗ рестарта; сброс счётчика лишь переоткладывает реап на `reaper_dead_ticks` тиков. |
|
||||
| R-9 | **never-raise нарушен** — необработанное исключение валит daemon-поток reaper → защита тихо отключается. | Низк. | Сред. | Per-job изоляция `try/except` (образец `reconciler.reconcile_gate_once`) + внешний `try/except` в `_run`. Юнит-тест TC-08/TC-14. |
|
||||
| R-10 | **Регресс существующих тестов** merge_gate/queue/reconciler/deploy. | Низк. | Сред. | Контракты неизменны (`STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/exit-коды хука); только новая колонка + новый поток + флаги (дефолт сохраняет поведение). Полный прогон `pytest tests/ -q` (regression в 04-test-plan). |
|
||||
|
||||
## Открытые вопросы / follow-up
|
||||
- **Полная автоматизация merge-финализации.** Если деплой-merge (deployer/ORCH-36
|
||||
detached host-process) окажется не полностью идемпотентным к повторному проходу,
|
||||
может понадобиться доп. работа поверх `pr_already_merged`. Здесь закрываем
|
||||
технический блокер; полный авто-approve деплоя — ORCH-54.
|
||||
- Допущение «агенты — дочерние процессы в одном pid-namespace» (R-3) должно быть
|
||||
пересмотрено, если упаковка агентов изменится (отдельные контейнеры).
|
||||
70
docs/work-items/ORCH-065/12-review.md
Normal file
70
docs/work-items/ORCH-065/12-review.md
Normal file
@@ -0,0 +1,70 @@
|
||||
---
|
||||
type: review
|
||||
work_item_id: ORCH-065
|
||||
verdict: APPROVED
|
||||
version: 3
|
||||
---
|
||||
|
||||
# Review ORCH-065
|
||||
|
||||
## Summary
|
||||
|
||||
Задача закрывает три связанных класса отказов «процесс/поток умер, а ресурс остался
|
||||
захваченным навсегда»: zombie jobs (A), залипший merge-lease (B), неидемпотентная
|
||||
финализация merge (C). Реализация качественная: новый daemon-поток `src/job_reaper.py`
|
||||
по образцу `reconciler` (never-raise, kill-switch, снимок в `/queue`), трёхуровневая
|
||||
liveness, атомарный `reap_running_job(... WHERE status='running')`, проактивный реклейм
|
||||
lease (`pid_alive` + `reclaim_stale_lease`), идемпотентный guard `pr_already_merged`,
|
||||
колонка `jobs.pid` через идемпотентный `_ensure_column`.
|
||||
|
||||
**Все блокеры предыдущих ревью устранены:**
|
||||
- v1 P0 (guard `pr_already_merged` не подключён к merge-пути) — устранён `aa46e5d`:
|
||||
промпт `.openclaw/agents/deployer.md` консультирует `pr_already_merged` ПЕРЕД любым
|
||||
(повторным) merge (AC-11 wiring на месте, подтверждено строками 94–105/152).
|
||||
- v2 P1 (Tier-2 реапит живой финализирующий monitor; side-effects ДО атомарного claim,
|
||||
нарушение ADR-001 Р-1) — устранён `3e2eb27` двумя мерами:
|
||||
1. **Tier-2 finalization grace** — новая колонка `finished_age_s` в `get_running_jobs`
|
||||
(`src/db.py:609`) + настройка `reaper_finalize_grace_s` (дефолт 300с); Tier-2
|
||||
реапит только при `finished_age >= grace`, иначе строка не трогается
|
||||
(`src/job_reaper.py:197-209`). Живой финализирующий monitor больше не реапится
|
||||
(FR-1.3/AC-3).
|
||||
2. **claim-before-act** — `_reap_exit0` (`src/job_reaper.py:242-286`) сначала оценивает
|
||||
канонический QG read-only (`_gate_is_green` → `_run_qg`, без побочных эффектов),
|
||||
затем атомарно claim `done` ПЕРВЫМ, и только победитель claim выполняет
|
||||
`_gate_driven_advance`. Проигравший гонку (поздний monitor / стартовый requeue) не
|
||||
делает НИКАКИХ побочных эффектов → нет дубль-advance/дубль-enqueue (FR-1.2/AC-4).
|
||||
- v2 P3 (битая ссылка на adr-0011 в CHANGELOG) — исправлена в `3e2eb27`
|
||||
(`adr-0011-job-reaper-lease-reclaim.md`).
|
||||
|
||||
Инварианты сохранены (AC-13): ORCH-065-коммиты (`1a2e881`/`aa46e5d`/`3e2eb27`) НЕ касаются
|
||||
`src/stages.py` и `src/qg/checks.py` — `STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/БАГ-8/
|
||||
exit-коды хука не тронуты; реклейм lease — только удаление файла, без git-операций
|
||||
(AC-12). Документация (README, internals, ADR-001, глобальный adr-0011, CHANGELOG,
|
||||
.env.example) обновлена в этом же PR (AC-17). Новые тесты покрывают grace-окно,
|
||||
lost-claim-no-side-effects, already-advanced-идемпотентность. `pytest tests/ -q` —
|
||||
**747 passed**.
|
||||
|
||||
## Findings
|
||||
|
||||
### P0 — Blocker
|
||||
- нет
|
||||
|
||||
### P1 — Must fix
|
||||
- нет
|
||||
|
||||
### P2 — Should fix
|
||||
- нет
|
||||
|
||||
### P3 — Nice to have
|
||||
- нет
|
||||
|
||||
## Документация
|
||||
|
||||
Обновлена корректно и в этом же PR (AC-17 PASS): `docs/architecture/README.md`
|
||||
(раздел про job-reaper + lease-reclaim, таблицы БД и `/queue`),
|
||||
`docs/architecture/internals.md`, `docs/architecture/adr/adr-0011-job-reaper-lease-reclaim.md`
|
||||
(+ запись в `adr/README.md`),
|
||||
`docs/work-items/ORCH-065/06-adr/ADR-001-job-reaper-and-lease-reclaim.md`, `CHANGELOG.md`
|
||||
(ссылка на adr-0011 исправлена), `.env.example` (флаги `ORCH_REAPER_*` /
|
||||
`ORCH_REAPER_FINALIZE_GRACE_S` / `ORCH_LEASE_RECLAIM_ENABLED`). ADR-001 Р-1 и реализация
|
||||
exit0-пути теперь согласованы (claim-before-act).
|
||||
92
docs/work-items/ORCH-065/13-test-report.md
Normal file
92
docs/work-items/ORCH-065/13-test-report.md
Normal file
@@ -0,0 +1,92 @@
|
||||
---
|
||||
type: test-report
|
||||
work_item_id: ORCH-065
|
||||
result: PASS
|
||||
---
|
||||
|
||||
# Test Report — ORCH-065
|
||||
|
||||
Тема: job-reaper + проактивный реклейм stale/dead merge-lease + идемпотентная
|
||||
финализация merge. Прогон полного регресса в ветке
|
||||
`feature/ORCH-065-bug-zombie-jobs-merge-lease-ru`. Review-вердикт — APPROVED (v3).
|
||||
|
||||
## Окружение
|
||||
- Python: 3.12.13
|
||||
- pytest: 8.3.3
|
||||
- Ветка: feature/ORCH-065-bug-zombie-jobs-merge-lease-ru (worktree)
|
||||
- Прод (8500): health `200 {"status":"ok"}` — НЕ перезапускался (self-hosting инвариант соблюдён)
|
||||
- Дата: 2026-06-07
|
||||
|
||||
## Smoke API (прод 8500, read-only)
|
||||
| Endpoint | Результат |
|
||||
|----------|-----------|
|
||||
| `GET /health` | 200 `{"status":"ok","service":"orchestrator"}` |
|
||||
| `GET /status` | 200, активные задачи отдаются (ORCH-065 в `testing`, ET-013 в `development`) |
|
||||
| `GET /queue` | 200, counts/resilience/reconcile/post_deploy присутствуют |
|
||||
|
||||
Примечание: блок `reaper` в `/queue` прода (8500) ОТСУТСТВУЕТ — ожидаемо, т.к. прод
|
||||
исполняет ещё не задеплоенный (до-ORCH-065) код. Контракт блока `reaper` проверен
|
||||
тестом TC-18 (`tests/test_queue.py::test_tc18_queue_endpoint_has_reaper_block`)
|
||||
против кода ветки — PASS. Curl недоступен в окружении, smoke выполнен через
|
||||
`urllib.request` (read-only, без побочных эффектов на прод).
|
||||
|
||||
## Результаты по тест-плану (04-test-plan.yaml)
|
||||
|
||||
| TC ID | Тип | Модуль | Покрывает | Результат |
|
||||
|-------|-----|--------|-----------|-----------|
|
||||
| TC-01 | unit | test_job_reaper.py | AC-1 (реап мёртвого job без рестарта) | PASS |
|
||||
| TC-02 | unit | test_job_reaper.py | AC-3 (живой агент не реапится) | PASS |
|
||||
| TC-03 | unit | test_job_reaper.py | FR-1.3 (устойчивость reaper_dead_ticks) | PASS |
|
||||
| TC-04 | unit | test_job_reaper.py | FR-1.1/AC-1 (backstop reaper_max_running_s) | PASS |
|
||||
| TC-05 | unit | test_job_reaper.py | AC-4 (исход по результату: done/queued/failed) | PASS |
|
||||
| TC-06 | unit | test_job_reaper.py | AC-5 (атомарность reap-UPDATE guard) | PASS |
|
||||
| TC-07 | unit | test_job_reaper.py | AC-14 (kill-switch reaper_enabled=false) | PASS |
|
||||
| TC-08 | unit | test_job_reaper.py | AC-9 (never-raise per-job) | PASS |
|
||||
| TC-09 | integration | test_queue.py | AC-2 (разблокировка очереди concurrency=1) | PASS |
|
||||
| TC-10 | unit | test_merge_lease_reclaim.py | AC-6 (реклейм lease мёртвого pid) | PASS |
|
||||
| TC-11 | unit | test_merge_lease_reclaim.py | AC-7 (реклейм по TTL сохранён) | PASS |
|
||||
| TC-12 | unit | test_merge_lease_reclaim.py | AC-8 (живой lease не трогается) | PASS |
|
||||
| TC-13 | unit | test_merge_lease_reclaim.py | AC-9 (условность self-hosting/no-op) | PASS |
|
||||
| TC-14 | unit | test_merge_lease_reclaim.py | AC-9 (never-raise при ошибке lease-файла) | PASS |
|
||||
| TC-15 | unit | test_merge_lease_reclaim.py | AC-14 (kill-switch lease_reclaim_enabled=false) | PASS |
|
||||
| TC-16 | unit | test_merge_gate.py | AC-11 (идемпотентность при уже слитом PR) | PASS |
|
||||
| TC-17 | integration | test_merge_gate_race.py | AC-10 (докатывание незавершённого merge) | PASS |
|
||||
| TC-18 | integration | test_queue.py | AC-15 (блок reaper в /queue) | PASS |
|
||||
| TC-19 | unit | test_config.py | AC-13 (контракты STAGE_TRANSITIONS/QG_CHECKS неизменны) | PASS |
|
||||
| TC-20 | unit | test_config.py | §5/AC-14 (новые настройки reaper_*/lease_reclaim_*) | PASS |
|
||||
| TC-21 | unit | test_job_reaper.py | FR-2.1/AC-6 (стартовый реклейм в lifespan) | PASS |
|
||||
|
||||
Все 21 TC из плана — PASS.
|
||||
|
||||
## Сопоставление с критериями приёмки (03-acceptance-criteria.md)
|
||||
- A (AC-1…AC-5): job-reaper — покрыты TC-01..TC-06, TC-09 → PASS
|
||||
- B (AC-6…AC-9): lease-reclaim — покрыты TC-10..TC-15 → PASS
|
||||
- C (AC-10, AC-11): идемпотентная финализация — TC-16, TC-17 → PASS
|
||||
- D (AC-12 прод не трогается, AC-13 контракты, AC-14 kill-switches): TC-07, TC-15, TC-19, TC-20 + smoke прода без рестарта → PASS
|
||||
- E (AC-15 /queue, AC-16 логи/алерты): TC-18 → PASS
|
||||
- F (AC-17 документация): review подтвердил обновление README/internals/ADR-001/adr-0011/CHANGELOG/.env.example (APPROVED) → PASS
|
||||
- G (AC-18 регресс зелёный): `pytest tests/` 747 passed → PASS
|
||||
|
||||
## Вывод pytest
|
||||
|
||||
### Целевые модули плана
|
||||
```
|
||||
$ python -m pytest tests/test_job_reaper.py tests/test_merge_lease_reclaim.py \
|
||||
tests/test_merge_gate.py tests/test_merge_gate_race.py \
|
||||
tests/test_queue.py tests/test_config.py -q
|
||||
92 passed, 1 warning in 3.40s
|
||||
```
|
||||
|
||||
### Полный регресс
|
||||
```
|
||||
$ python -m pytest tests/ -v --tb=short
|
||||
======================= 747 passed, 1 warning in 15.47s ========================
|
||||
```
|
||||
(1 warning — PydanticDeprecatedSince20 в src/config.py, не связан с ORCH-065,
|
||||
предсуществующий.)
|
||||
|
||||
## Итог
|
||||
**PASS.** Полный регресс — 747 passed, 0 failed. Все 21 TC тест-плана зелёные,
|
||||
все критерии приёмки (AC-1…AC-18) подтверждены. Smoke прода — health/status/queue
|
||||
200 OK, прод-контейнер не перезапускался (self-hosting инвариант соблюдён).
|
||||
Задача готова к переходу на стадию `deploy-staging`.
|
||||
32
docs/work-items/ORCH-065/15-staging-log.md
Normal file
32
docs/work-items/ORCH-065/15-staging-log.md
Normal file
@@ -0,0 +1,32 @@
|
||||
---
|
||||
staging_status: SUCCESS
|
||||
timestamp: 2026-06-07T16:13:48Z
|
||||
base_url: http://localhost:8501
|
||||
---
|
||||
|
||||
# Staging Gate Log
|
||||
|
||||
Staging test suite completed against the live staging environment (8501),
|
||||
run canonically inside the `orchestrator-staging` container (ORCH-048, ADR-001).
|
||||
|
||||
**Verdict:** SUCCESS — `staging_check.py` exited **0**. All REAL (pipeline)
|
||||
checks green; the only failures are the two known sandbox-infra checks
|
||||
(C9a/C9b), which are waived per ORCH-061 because every REAL check passed.
|
||||
|
||||
## Result
|
||||
|
||||
- RESULT: 8/10 checks PASS
|
||||
- REAL failed: none
|
||||
- SANDBOX_INFRA failed (waived): C9a Branch appears in orchestrator-sandbox; C9b Analyst job enqueued in staging queue
|
||||
|
||||
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 detail
|
||||
|
||||
- [Block A] SMOKE — A1 /health, A2 /queue, A3 ORCH_STAGING=true → PASS
|
||||
- [Block B] ACCESS — B4 Plane sandbox, B5 Gitea orchestrator-sandbox (push=true), B6 registry isolation (sandbox present, prod ET/ORCH absent) → PASS
|
||||
- [Block C] E2E (stub) — C7 create issue in SANDBOX, C8 trigger pipeline via /webhook/plane → PASS; C9a/C9b → FAIL (sandbox-infra, waived)
|
||||
- CLEANUP — Plane issue deleted (HTTP 204)
|
||||
|
||||
tolerance: staging_infra_tolerance_enabled=True
|
||||
@@ -4,3 +4,8 @@ pydantic-settings==2.5.0
|
||||
httpx==0.27.0
|
||||
pytest==8.3.3
|
||||
pytest-asyncio==0.23.8
|
||||
# ORCH-022: dependency audit (OSV/PyPI advisory) for the security-gate. Needs the
|
||||
# network at scan time -> an unreachable feed degrades fail-open + warning by
|
||||
# default (ADR-001 Р-3 / 07-infra I-2). gitleaks (secret-scan) is a pinned Go
|
||||
# binary baked into the Dockerfile, NOT a pip package.
|
||||
pip-audit==2.7.3
|
||||
|
||||
@@ -417,6 +417,14 @@ class AgentLauncher:
|
||||
"UPDATE agent_runs SET output_path = ? WHERE id = ?",
|
||||
(output_path, run_id),
|
||||
)
|
||||
# ORCH-065: stamp the agent process pid onto the job row so the job-reaper
|
||||
# can probe liveness (os.kill(pid, 0)). proc.pid only exists after Popen,
|
||||
# so this is a second UPDATE next to run_id/started_at (set above in _spawn).
|
||||
if job_id is not None:
|
||||
conn.execute(
|
||||
"UPDATE jobs SET pid = ? WHERE id = ?",
|
||||
(proc.pid, job_id),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
@@ -219,6 +219,36 @@ class Settings(BaseSettings):
|
||||
image_freshness_enabled: bool = True
|
||||
image_freshness_repos: str = ""
|
||||
|
||||
# ORCH-022: security-gate (secret-scanning + dependency audit) on the
|
||||
# deploy-staging -> deploy edge, run FIRST among the edge sub-gates (cheap to
|
||||
# fail before the expensive rebase/rebuild). Deterministic (no LLM): gitleaks
|
||||
# (offline secret-scan) + pip-audit (OSV/PyPI dependency audit), verdict in the
|
||||
# versioned 17-security-report.md frontmatter; FAIL -> rollback to development +
|
||||
# developer-retry (cap MAX_DEVELOPER_RETRIES). See ADR-001-security-gate.md.
|
||||
# security_gate_enabled -> SINGLE kill-switch; False -> pipeline 1:1 as
|
||||
# before ORCH-022 for everyone. Env
|
||||
# ORCH_SECURITY_GATE_ENABLED.
|
||||
# security_gate_repos -> CSV of repos where the gate is REAL; empty ->
|
||||
# only the self-hosting repo (orchestrator).
|
||||
# Mirrors merge_gate_repos / image_freshness_repos.
|
||||
# security_dep_block_severity -> CVE severity threshold that BLOCKS (CRITICAL >
|
||||
# HIGH > MEDIUM > LOW); below it / UNKNOWN -> a
|
||||
# warning only (anti-loop ADR-001 Р-4).
|
||||
# security_scan_timeout_s -> per external scanner call timeout (mirrors
|
||||
# merge_retest_timeout_s).
|
||||
# security_dep_audit_fail_closed -> strict mode: an unreachable CVE feed -> FAIL
|
||||
# instead of the default fail-open + warning
|
||||
# (Р-3). Default False (anti-loop ORCH-061).
|
||||
# security_secrets_block -> a found secret blocks (always True by default;
|
||||
# the offline secrets guarantee is unconditional,
|
||||
# BR-2).
|
||||
security_gate_enabled: bool = True
|
||||
security_gate_repos: str = ""
|
||||
security_dep_block_severity: str = "HIGH"
|
||||
security_scan_timeout_s: int = 300
|
||||
security_dep_audit_fail_closed: bool = False
|
||||
security_secrets_block: bool = True
|
||||
|
||||
# ORCH-061: tolerate KNOWN sandbox-infra FAILs (C9a/C9b) in the staging suite.
|
||||
# The self-hosting deploy-staging stage looped because scripts/staging_check.py
|
||||
# exited non-zero on ANY failed check, so two infra-only failures (sandbox bot
|
||||
@@ -296,6 +326,42 @@ class Settings(BaseSettings):
|
||||
post_deploy_auto_rollback: bool = False
|
||||
post_deploy_base_url: str = "http://localhost:8500"
|
||||
|
||||
# ORCH-065: job-reaper + proactive merge-lease reclaim. A background daemon
|
||||
# thread (modelled on the reconciler) makes "the monitor thread / process died
|
||||
# while a job/lease was held" self-heal WITHOUT a restart. Status (done/queued/
|
||||
# failed) is otherwise only ever set by launcher._monitor_agent -> _finalize_job
|
||||
# inside the live process; a death there left the jobs row 'running' forever and
|
||||
# (at max_concurrency=1) wedged the queue of EVERY project (incidents 07.06: jobs
|
||||
# 236/239/242/254). The same thread proactively reclaims a stale/dead merge-lease
|
||||
# (ORCH-043) instead of waiting for the lazy TTL on the next foreign acquire. See
|
||||
# docs/architecture/adr/adr-0011-job-reaper-lease-reclaim.md.
|
||||
# reaper_enabled -> global kill-switch (false -> strictly prior behaviour;
|
||||
# only the startup requeue_running_jobs remains).
|
||||
# reaper_interval_s -> background scan period (seconds).
|
||||
# reaper_dead_ticks -> Tier-1: consecutive ticks a job's pid must be dead
|
||||
# before it is reaped (>=2 anti-false-positive; a live
|
||||
# long-running agent is NEVER reaped).
|
||||
# reaper_max_running_s -> Tier-3 backstop ceiling: a job 'running' longer than
|
||||
# this is reaped even when liveness is unknowable. MUST be
|
||||
# > max agent_timeout + grace so a legit agent is safe.
|
||||
# reaper_finalize_grace_s -> Tier-2 anti-false-positive: a LIVE monitor writes
|
||||
# agent_runs.exit_code FIRST, THEN does git commit/push +
|
||||
# PR + Plane usage comments (seconds..minutes) and only
|
||||
# then _finalize_job. The agent pid is already dead in
|
||||
# that window, so pid cannot tell "monitor died" from
|
||||
# "monitor still finalizing". A job is reaped via Tier-2
|
||||
# only once exit_code has been recorded for at least this
|
||||
# many seconds (MUST be > the max finalization window).
|
||||
# lease_reclaim_enabled -> kill-switch for the proactive stale/dead lease reclaim
|
||||
# (false -> only the legacy lazy TTL reclaim in acquire).
|
||||
# (reuse) merge_lock_timeout_s -> lease TTL; merge_gate_repos -> reclaim scope.
|
||||
reaper_enabled: bool = True
|
||||
reaper_interval_s: int = 60
|
||||
reaper_dead_ticks: int = 2
|
||||
reaper_max_running_s: int = 3600
|
||||
reaper_finalize_grace_s: int = 300
|
||||
lease_reclaim_enabled: bool = True
|
||||
|
||||
# Telegram notifications
|
||||
telegram_bot_token: str = ""
|
||||
telegram_chat_id: str = ""
|
||||
|
||||
81
src/db.py
81
src/db.py
@@ -76,6 +76,11 @@ def init_db():
|
||||
# (CREATE TABLE IF NOT EXISTS won't add columns to an already-created table).
|
||||
_ensure_column(conn, "jobs", "transient_attempts", "INTEGER NOT NULL DEFAULT 0")
|
||||
_ensure_column(conn, "jobs", "available_at", "TEXT")
|
||||
# ORCH-065: pid of the spawned agent process, stamped in launcher._spawn next to
|
||||
# run_id/started_at. The job-reaper uses it for Tier-1 liveness (os.kill(pid, 0))
|
||||
# to detect a 'running' job whose process died before _finalize_job. Idempotent
|
||||
# ALTER (no-op once present) -> safe on the live prod DB.
|
||||
_ensure_column(conn, "jobs", "pid", "INTEGER")
|
||||
# ORCH-5 (M-7): webhook delivery de-dup. Add events.delivery_id and a PARTIAL
|
||||
# unique index. Partial (WHERE delivery_id IS NOT NULL) so pre-existing rows
|
||||
# (which have NULL delivery_id) never collide with each other. Restart-safe:
|
||||
@@ -593,6 +598,82 @@ def requeue_running_jobs() -> int:
|
||||
return int(n)
|
||||
|
||||
|
||||
def get_running_jobs() -> list[dict]:
|
||||
"""ORCH-065: snapshot of every 'running' job for the job-reaper scan.
|
||||
|
||||
Each row carries the job columns plus four reaper inputs:
|
||||
* ``running_age_s`` — seconds since ``started_at`` (Tier-3 backstop);
|
||||
* ``exit_code`` — the linked ``agent_runs.exit_code`` (Tier-2: process
|
||||
finished but the job is still 'running' -> monitor died mid-finalize);
|
||||
* ``finished_at_run`` — the linked ``agent_runs.finished_at``;
|
||||
* ``finished_age_s`` — seconds since ``agent_runs.finished_at`` (Tier-2
|
||||
finalization grace: a LIVE monitor writes exit_code, THEN does git
|
||||
push / PR / Plane comments before _finalize_job, so a freshly-finished
|
||||
run is NOT yet a zombie — the reaper waits ``reaper_finalize_grace_s``).
|
||||
|
||||
A LEFT JOIN on ``run_id`` keeps jobs with no agent_runs row (exit_code NULL).
|
||||
Read-only; never mutates. The reaper applies liveness/streak/backstop on top.
|
||||
"""
|
||||
conn = get_db()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT j.*, "
|
||||
"CAST(strftime('%s','now') - strftime('%s', j.started_at) AS INTEGER) "
|
||||
" AS running_age_s, "
|
||||
"r.exit_code AS exit_code, r.finished_at AS finished_at_run, "
|
||||
"CAST(strftime('%s','now') - strftime('%s', r.finished_at) AS INTEGER) "
|
||||
" AS finished_age_s "
|
||||
"FROM jobs j LEFT JOIN agent_runs r ON r.id = j.run_id "
|
||||
"WHERE j.status='running'"
|
||||
).fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def reap_running_job(
|
||||
job_id: int,
|
||||
status: str,
|
||||
run_id: int | None = None,
|
||||
error: str | None = None,
|
||||
) -> bool:
|
||||
"""ORCH-065: atomic terminal flip of a RUNNING job by the job-reaper.
|
||||
|
||||
Mirrors ``mark_job`` but carries the ``status='running'`` guard in the WHERE
|
||||
clause and reports ``rowcount`` so a late-arriving monitor / the startup
|
||||
``requeue_running_jobs`` / a second reaper tick can never double-process the
|
||||
same row (AC-5, restart-safe). Returns True iff THIS call won the flip
|
||||
(rowcount == 1); False -> someone else already moved the row.
|
||||
|
||||
Status semantics match ``mark_job``: done/failed stamp ``finished_at``; queued
|
||||
clears ``started_at``/``finished_at`` so the next claim treats it as fresh.
|
||||
"""
|
||||
conn = get_db()
|
||||
try:
|
||||
sets = ["status = ?"]
|
||||
params: list = [status]
|
||||
if run_id is not None:
|
||||
sets.append("run_id = ?")
|
||||
params.append(run_id)
|
||||
if error is not None:
|
||||
sets.append("error = ?")
|
||||
params.append(error)
|
||||
if status in ("done", "failed"):
|
||||
sets.append("finished_at = datetime('now')")
|
||||
elif status == "queued":
|
||||
sets.append("started_at = NULL")
|
||||
sets.append("finished_at = NULL")
|
||||
params.append(job_id)
|
||||
cur = conn.execute(
|
||||
f"UPDATE jobs SET {', '.join(sets)} WHERE id = ? AND status='running'",
|
||||
params,
|
||||
)
|
||||
conn.commit()
|
||||
return cur.rowcount == 1
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_job(job_id: int) -> dict | None:
|
||||
"""Fetch a single job by id."""
|
||||
conn = get_db()
|
||||
|
||||
467
src/job_reaper.py
Normal file
467
src/job_reaper.py
Normal file
@@ -0,0 +1,467 @@
|
||||
"""ORCH-065: job-reaper + proactive merge-lease reclaim background daemon.
|
||||
|
||||
Three failure classes share one root cause — "the thread/process died while it
|
||||
still held captured state" — and one inert recovery layer
|
||||
(``requeue_running_jobs``) that only fires on a process restart:
|
||||
|
||||
* **A — zombie jobs.** A job's terminal status (``done``/``queued``/``failed``)
|
||||
is written ONLY inside ``launcher._monitor_agent -> _finalize_job`` in the
|
||||
live process. If that thread/process dies between ``proc.wait()`` and the
|
||||
status write (crash, OOM, self-restart mid-deploy) the ``jobs`` row stays
|
||||
``running`` forever. At ``max_concurrency=1`` one zombie blocks the claim of
|
||||
EVERY project's jobs -> the whole shared pipeline stalls.
|
||||
* **B — stuck merge-lease.** The file lease ``.merge-lease-<repo>.json``
|
||||
(ORCH-043) is reclaimed only lazily, by TTL, and only when ANOTHER task tries
|
||||
to acquire it. Holder liveness (pid) is never probed, so a death with the
|
||||
lease held blocks foreign merges until the TTL expires.
|
||||
|
||||
This module is a background daemon thread modelled on ``reconciler``
|
||||
(``threading.Thread(daemon=True)`` + ``threading.Event``, start/stop in
|
||||
``main.lifespan``, ``/queue`` snapshot, per-unit never-raise, kill-switch). Each
|
||||
tick: (1) scans ``running`` jobs and reaps the dead ones via three-tier liveness
|
||||
detection; (2) proactively reclaims dead/stale merge-leases (mechanism B) for the
|
||||
in-scope repos.
|
||||
|
||||
Liveness (defense in depth, ADR-001 Р-1):
|
||||
* **Tier-1 (primary): dead pid.** ``jobs.pid`` (stamped by ``launcher._spawn``)
|
||||
probed with ``merge_gate.pid_alive``. A job is reaped only after
|
||||
``reaper_dead_ticks`` (>=2) CONSECUTIVE dead-pid ticks — an in-memory streak
|
||||
counter kills false positives (AC-3); a live agent within its timeout is
|
||||
never reaped.
|
||||
* **Tier-2 (completion race): exit_code recorded but job still running.** This
|
||||
window is AMBIGUOUS — it is both "the monitor died between writing
|
||||
``agent_runs.exit_code`` and ``_finalize_job``" AND "a LIVE monitor is still
|
||||
finalizing" (``_monitor_agent`` writes ``exit_code`` FIRST, then git
|
||||
commit/push (+PR), the БАГ-8 check and network Plane usage comments — seconds
|
||||
to tens of seconds — and ONLY THEN ``_try_advance_stage`` -> ``_finalize_job``).
|
||||
The agent pid is already dead in BOTH cases, so it cannot disambiguate. The
|
||||
reaper therefore treats it as a dead monitor (KNOWN outcome) only after a
|
||||
finalization grace: ``exit_code`` recorded for >= ``reaper_finalize_grace_s``
|
||||
(a live finalizing monitor is NEVER reaped, FR-1.3/AC-3). Within the grace the
|
||||
row is left untouched.
|
||||
* **Tier-3 (backstop): age ceiling.** A job ``running`` longer than
|
||||
``reaper_max_running_s`` (deliberately > max ``agent_timeout`` + grace) is
|
||||
reaped even when liveness cannot be determined (pid reused / unknown).
|
||||
|
||||
Action on confirmed death reuses existing contracts (no new merge/stage logic):
|
||||
* The reaper's ONLY mutating write to a job row is the atomic terminal flip
|
||||
``db.reap_running_job(... WHERE status='running')`` — so a late-arriving
|
||||
monitor / the startup ``requeue_running_jobs`` / a second tick can never
|
||||
double-process a row (AC-5; the loser sees ``rowcount==0``).
|
||||
* **exit0 (Tier-2): claim-BEFORE-act (ADR-001 Р-1).** The source of truth is the
|
||||
canonical quality gate, NOT "exit0". If the stage already advanced -> atomic
|
||||
``done`` claim only (idempotent cleanup). Else evaluate the canonical QG
|
||||
READ-ONLY (no side effects, the reconciler pattern): red (e.g. the monitor died
|
||||
before git-push, so no artifact) -> failure path (no false ``done``); green ->
|
||||
atomically claim ``done`` FIRST, and only the claim winner then runs
|
||||
``launcher._try_advance_stage`` (advance + ``enqueue_job`` of the next stage).
|
||||
A tick that loses the claim performs NO side effects, so a late-finalizing
|
||||
monitor / the startup ``requeue_running_jobs`` can never be double-advanced or
|
||||
double-enqueued.
|
||||
* **exit!=0 (Tier-2) / unknown outcome (Tier-1 dead pid, Tier-3 backstop):**
|
||||
``attempts < max_attempts`` -> ``queued`` (mirrors ``requeue_running_jobs``);
|
||||
budget exhausted -> ``failed`` + Telegram. We never fabricate exit0.
|
||||
|
||||
Invariants (ТЗ §8 / ADR-001): never-raise per unit of work; idempotency (atomic
|
||||
guard + gate-driven advance); restart-safe (the reaper starts AFTER the startup
|
||||
``requeue_running_jobs``); silence when nothing is anomalous; the reaper NEVER
|
||||
restarts/kills the prod container and NEVER pushes ``main``. ``STAGE_TRANSITIONS``
|
||||
/ ``QG_CHECKS`` and every ``check_*`` signature are unchanged.
|
||||
|
||||
See docs/work-items/ORCH-065/06-adr/ADR-001-job-reaper-and-lease-reclaim.md and
|
||||
the cross-cutting docs/architecture/adr/adr-0011-job-reaper-lease-reclaim.md.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from .config import settings
|
||||
from .db import (
|
||||
get_db,
|
||||
get_running_jobs,
|
||||
reap_running_job,
|
||||
)
|
||||
from .stages import STAGE_TRANSITIONS, get_agent_for_stage
|
||||
|
||||
logger = logging.getLogger("orchestrator.job_reaper")
|
||||
|
||||
|
||||
def reclaim_all_stale_leases() -> int:
|
||||
"""Proactively reclaim dead/stale merge-leases for every in-scope repo.
|
||||
|
||||
Used both at startup (``main.lifespan``, next to ``requeue_running_jobs``) and
|
||||
on every reaper tick (mechanism B). Iterates the merge-gate scope
|
||||
(``merge_gate_repos`` CSV, else self-hosting ``orchestrator``) and calls the
|
||||
never-raise ``merge_gate.reclaim_stale_lease`` per repo. Returns the number of
|
||||
leases actually reclaimed. Never raises (per-repo isolation).
|
||||
"""
|
||||
if not settings.lease_reclaim_enabled:
|
||||
return 0
|
||||
reclaimed = 0
|
||||
try:
|
||||
from . import merge_gate
|
||||
raw = (settings.merge_gate_repos or "").strip()
|
||||
if raw:
|
||||
repos = [r.strip() for r in raw.split(",") if r.strip()]
|
||||
else:
|
||||
from .qg.checks import SELF_HOSTING_REPO
|
||||
repos = [SELF_HOSTING_REPO]
|
||||
for repo in repos:
|
||||
try:
|
||||
if merge_gate.reclaim_stale_lease(repo):
|
||||
reclaimed += 1
|
||||
except Exception as e: # noqa: BLE001 - isolate one repo's failure
|
||||
logger.error("lease-reclaim failed for repo %s: %s", repo, e)
|
||||
except Exception as e: # noqa: BLE001 - never-raise contract
|
||||
logger.error("reclaim_all_stale_leases error: %s", e)
|
||||
return reclaimed
|
||||
|
||||
|
||||
class JobReaper:
|
||||
"""Background daemon that reaps zombie jobs and reclaims stale merge-leases.
|
||||
|
||||
Modelled on ``Reconciler``: a ``threading.Thread(daemon=True)`` + a
|
||||
``threading.Event`` for a clean stop. The only in-memory state is the
|
||||
best-effort Tier-1 dead-pid streak counter (``_streak``) and the
|
||||
observability counters (``reaped_total`` / ``last_reaped`` /
|
||||
``lease_reclaimed_total`` / ``last_run_ts``); all reset on restart, which is
|
||||
safe because the startup ``requeue_running_jobs`` covers the restart path.
|
||||
"""
|
||||
|
||||
def __init__(self, interval_s: float | None = None):
|
||||
self.interval_s = (
|
||||
interval_s if interval_s is not None else settings.reaper_interval_s
|
||||
)
|
||||
self._stop = threading.Event()
|
||||
self._thread: threading.Thread | None = None
|
||||
# Tier-1 anti-false-positive: {job_id: consecutive dead-pid ticks}.
|
||||
self._streak: dict[int, int] = {}
|
||||
# Best-effort observability (Р-6).
|
||||
self.last_run_ts: float | None = None
|
||||
self.reaped_total: int = 0
|
||||
self.last_reaped: dict | None = None
|
||||
self.lease_reclaimed_total: int = 0
|
||||
|
||||
# -- A: zombie-job reaping --------------------------------------------
|
||||
def reap_once(self) -> None:
|
||||
"""One scan over all ``running`` jobs (per-job never-raise) + lease reclaim."""
|
||||
if settings.reaper_enabled:
|
||||
try:
|
||||
running = get_running_jobs()
|
||||
except Exception as e: # noqa: BLE001 - never break the tick
|
||||
logger.error("reaper: get_running_jobs failed: %s", e)
|
||||
running = []
|
||||
seen: set[int] = set()
|
||||
for job in running:
|
||||
jid = job.get("id")
|
||||
if jid is not None:
|
||||
seen.add(jid)
|
||||
try:
|
||||
self._reap_job(job)
|
||||
except Exception as e: # noqa: BLE001 - isolate one job's failure
|
||||
logger.error(
|
||||
"reaper: job %s (agent=%s) failed: %s",
|
||||
job.get("id"), job.get("agent"), e,
|
||||
)
|
||||
# Forget streaks for rows that are no longer running (reaped / requeued
|
||||
# / finished by the monitor) so the dict cannot grow unbounded.
|
||||
self._streak = {k: v for k, v in self._streak.items() if k in seen}
|
||||
# Mechanism B: proactive stale/dead lease reclaim (own kill-switch).
|
||||
try:
|
||||
self.lease_reclaimed_total += reclaim_all_stale_leases()
|
||||
except Exception as e: # noqa: BLE001 - never break the tick
|
||||
logger.error("reaper: lease reclaim sweep failed: %s", e)
|
||||
|
||||
def _reap_job(self, job: dict) -> None:
|
||||
"""Apply the three-tier liveness policy to a single running job."""
|
||||
from . import merge_gate
|
||||
|
||||
job_id = job["id"]
|
||||
pid = job.get("pid")
|
||||
age = int(job.get("running_age_s") or 0)
|
||||
exit_code = job.get("exit_code") # from the LEFT JOIN on agent_runs
|
||||
|
||||
# Tier-2: the process finished (exit_code recorded) but the job is still
|
||||
# 'running'. This is AMBIGUOUS: it is BOTH "the monitor died mid-finalize"
|
||||
# AND "a LIVE monitor is still finalizing" — _monitor_agent writes exit_code
|
||||
# FIRST, then does git commit/push (+PR), the БАГ-8 check, network Plane
|
||||
# usage comments (seconds..tens of seconds), and ONLY THEN _try_advance_stage
|
||||
# -> _finalize_job. The agent pid is already dead in BOTH cases, so pid can
|
||||
# NOT disambiguate. We treat it as a dead monitor (KNOWN outcome) only after
|
||||
# a finalization grace: exit_code must have been recorded for at least
|
||||
# `reaper_finalize_grace_s` (FR-1.3/AC-3 — a live finalizing monitor is never
|
||||
# reaped). Within the grace window we leave the row alone (and fall through to
|
||||
# the Tier-3 backstop only, which never trips before the grace given a sane
|
||||
# config where reaper_max_running_s > reaper_finalize_grace_s).
|
||||
if exit_code is not None:
|
||||
self._streak.pop(job_id, None)
|
||||
finished_age = job.get("finished_age_s")
|
||||
grace = int(settings.reaper_finalize_grace_s)
|
||||
if finished_age is not None and int(finished_age) >= grace:
|
||||
self._reap_known_outcome(job, int(exit_code))
|
||||
return
|
||||
logger.info(
|
||||
"reaper: job %s exit_code=%s recorded %ss ago (< grace %ss) — "
|
||||
"deferring (monitor may still be finalizing)",
|
||||
job_id, exit_code, finished_age, grace,
|
||||
)
|
||||
# fall through to the Tier-3 backstop guard below.
|
||||
else:
|
||||
# Tier-1: dead pid, only after `reaper_dead_ticks` consecutive dead ticks.
|
||||
if pid is not None and not merge_gate.pid_alive(pid):
|
||||
n = self._streak.get(job_id, 0) + 1
|
||||
self._streak[job_id] = n
|
||||
if n >= max(int(settings.reaper_dead_ticks), 1):
|
||||
self._streak.pop(job_id, None)
|
||||
self._reap_unknown_outcome(job, reason=f"dead pid={pid}")
|
||||
return
|
||||
logger.info(
|
||||
"reaper: job %s pid=%s dead (streak %d/%d) — deferring",
|
||||
job_id, pid, n, settings.reaper_dead_ticks,
|
||||
)
|
||||
else:
|
||||
# Alive / no pid -> reset the streak (must be CONSECUTIVE).
|
||||
self._streak.pop(job_id, None)
|
||||
|
||||
# Tier-3: backstop ceiling (one-shot; reaps even when liveness is unknown).
|
||||
if age >= int(settings.reaper_max_running_s):
|
||||
self._streak.pop(job_id, None)
|
||||
self._reap_unknown_outcome(
|
||||
job, reason=f"backstop age={age}s>={settings.reaper_max_running_s}s"
|
||||
)
|
||||
|
||||
# -- reap actions ------------------------------------------------------
|
||||
def _reap_known_outcome(self, job: dict, exit_code: int) -> None:
|
||||
"""Tier-2: the agent's exit_code is known; drive the job's terminal status."""
|
||||
if exit_code == 0:
|
||||
self._reap_exit0(job)
|
||||
else:
|
||||
self._reap_unknown_outcome(job, reason=f"exit={exit_code}")
|
||||
|
||||
def _reap_exit0(self, job: dict) -> None:
|
||||
"""Reap an exit0 Tier-2 job with claim-BEFORE-act (ADR-001 Р-1).
|
||||
|
||||
The atomic ``reap_running_job`` claim (guard ``WHERE status='running'``) MUST
|
||||
precede any ``advance_stage`` / ``enqueue_job`` side effect, so a reaper tick
|
||||
that LOSES the row (to a late-finalizing monitor or the startup
|
||||
``requeue_running_jobs``) performs NO side effects — no duplicate advance, no
|
||||
duplicate ``enqueue_job`` of the next stage (FR-1.2/AC-4).
|
||||
|
||||
Because the claim flips the row OUT of 'running', we cannot run the advance
|
||||
first to learn the gate colour. Instead we evaluate the canonical quality gate
|
||||
READ-ONLY (no side effects — the pattern the reconciler uses) to choose the
|
||||
terminal status BEFORE claiming:
|
||||
* already advanced past this agent -> idempotent clean ``done`` (no advance);
|
||||
* gate green -> claim ``done`` first, THEN advance exactly once;
|
||||
* gate red (e.g. monitor died before git-push -> no artifact) -> NOT a real
|
||||
success: route to the retry/fail contract (never a false ``done``).
|
||||
"""
|
||||
job_id = job["id"]
|
||||
run_id = job.get("run_id")
|
||||
agent = job.get("agent")
|
||||
branch, stage, work_item_id = self._task_meta(job)
|
||||
candidates = {s for s in STAGE_TRANSITIONS if get_agent_for_stage(s) == agent}
|
||||
|
||||
if stage is None or stage not in candidates:
|
||||
# Stage already advanced past this agent (or unknown) -> a clean 'done'
|
||||
# is correct WITHOUT re-advancing. Atomic claim only (idempotent cleanup).
|
||||
if reap_running_job(job_id, "done", run_id=run_id):
|
||||
self._note_reap(job, "done", reason="exit0, already advanced")
|
||||
return
|
||||
|
||||
if not branch or not self._gate_is_green(stage, job, branch, work_item_id):
|
||||
# exit0 but the gate is red -> do NOT fabricate 'done'; treat as failure
|
||||
# (retry within budget, else failed + Telegram).
|
||||
self._reap_unknown_outcome(job, reason="exit0 but gate red")
|
||||
return
|
||||
|
||||
# Gate green. CLAIM-BEFORE-ACT: own the row atomically FIRST.
|
||||
if not reap_running_job(job_id, "done", run_id=run_id):
|
||||
# Lost the race -> the winner (late monitor / startup requeue) owns the
|
||||
# advance; we do NOTHING (no duplicate side effects).
|
||||
return
|
||||
# We exclusively own the row now -> drive the gate-based advance exactly once.
|
||||
self._gate_driven_advance(job)
|
||||
self._note_reap(job, "done", reason="exit0, gate green")
|
||||
|
||||
def _gate_is_green(
|
||||
self, stage: str, job: dict, branch: str, work_item_id: str | None
|
||||
) -> bool:
|
||||
"""Read-only canonical-QG evaluation for a reaped exit0 job (no side effects).
|
||||
|
||||
Mirrors the reconciler's cheap pre-evaluation: dispatch the stage's QG via
|
||||
the SAME ``_run_qg`` the webhook path uses, returning its pass/fail WITHOUT
|
||||
running ``advance_stage`` (so no stage move / enqueue / notification happens
|
||||
here). A stage with no registered gate is treated as green (nothing blocks a
|
||||
clean 'done'). Never raises -> any error returns False (conservative: route
|
||||
to retry, never a false 'done').
|
||||
"""
|
||||
try:
|
||||
from .stages import get_qg_for_stage
|
||||
from .stage_engine import _run_qg
|
||||
qg_name = get_qg_for_stage(stage)
|
||||
if not qg_name:
|
||||
return True
|
||||
passed, _reason = _run_qg(qg_name, job.get("repo"), work_item_id, branch)
|
||||
return bool(passed)
|
||||
except Exception as e: # noqa: BLE001 - never break the reap
|
||||
logger.warning(
|
||||
"reaper: gate pre-eval failed for job %s (stage=%s): %s",
|
||||
job.get("id"), stage, e,
|
||||
)
|
||||
return False
|
||||
|
||||
def _reap_unknown_outcome(self, job: dict, reason: str) -> None:
|
||||
"""Tier-1/Tier-3 (or exit!=0): outcome not a clean success.
|
||||
|
||||
Mirrors ``requeue_running_jobs`` / the permanent-failure contract:
|
||||
``attempts < max_attempts`` -> ``queued`` (a retry); budget exhausted ->
|
||||
``failed`` + Telegram. The terminal flip is the atomic ``reap_running_job``
|
||||
guard, so a racing requeue/monitor never double-processes the row.
|
||||
"""
|
||||
job_id = job["id"]
|
||||
run_id = job.get("run_id")
|
||||
attempts = int(job.get("attempts") or 0)
|
||||
max_attempts = int(job.get("max_attempts") or 2)
|
||||
err = f"reaped: {reason} (run_id={run_id})"
|
||||
if attempts < max_attempts:
|
||||
if reap_running_job(job_id, "queued", run_id=run_id, error=err):
|
||||
self._note_reap(job, "queued", reason=reason)
|
||||
else:
|
||||
if reap_running_job(job_id, "failed", run_id=run_id, error=err):
|
||||
self._note_reap(job, "failed", reason=reason)
|
||||
self._notify_failed(job, reason)
|
||||
|
||||
def _gate_driven_advance(self, job: dict) -> bool:
|
||||
"""Idempotent, gate-driven stage advance for a reaped exit0 job.
|
||||
|
||||
Returns True iff the stage is (or has become) advanced past this agent's
|
||||
stage — i.e. the canonical quality gate is satisfied and a clean ``done``
|
||||
is correct. Returns False when the gate is still red (the caller then
|
||||
routes the job to the failure path instead of a false ``done``).
|
||||
|
||||
The advance itself reuses the UNCHANGED ``launcher._try_advance_stage``
|
||||
(which runs the canonical QG and the unified ``advance_stage``); the
|
||||
reaper never duplicates ``update_task_stage`` / ``enqueue_job``.
|
||||
"""
|
||||
agent = job.get("agent")
|
||||
repo = job.get("repo")
|
||||
run_id = job.get("run_id")
|
||||
branch, stage, _wid = self._task_meta(job)
|
||||
# Candidate stages whose finishing agent is THIS agent (deployer maps to
|
||||
# both 'testing' and 'deploy-staging', hence a set).
|
||||
candidates = {s for s in STAGE_TRANSITIONS if get_agent_for_stage(s) == agent}
|
||||
if stage is None or stage not in candidates:
|
||||
# Stage already advanced past this agent (or unknown) -> idempotent
|
||||
# cleanup: a clean 'done' is correct without re-advancing.
|
||||
return True
|
||||
if not branch:
|
||||
return False
|
||||
try:
|
||||
from .agents.launcher import launcher
|
||||
launcher._try_advance_stage(run_id, agent, repo, branch)
|
||||
except Exception as e: # noqa: BLE001 - never break the reap
|
||||
logger.error("reaper: gate-driven advance failed for job %s: %s",
|
||||
job.get("id"), e)
|
||||
return False
|
||||
# Re-read the stage: advanced out of the candidate set -> gate was green.
|
||||
_branch, new_stage, _wid2 = self._task_meta(job)
|
||||
return new_stage is None or new_stage not in candidates
|
||||
|
||||
@staticmethod
|
||||
def _task_meta(job: dict) -> tuple[str | None, str | None, str | None]:
|
||||
"""Resolve (branch, stage, work_item_id) for the job's task. Never raises."""
|
||||
task_id = job.get("task_id")
|
||||
if not task_id:
|
||||
return None, None, None
|
||||
try:
|
||||
conn = get_db()
|
||||
row = conn.execute(
|
||||
"SELECT branch, stage, work_item_id FROM tasks WHERE id = ?",
|
||||
(task_id,),
|
||||
).fetchone()
|
||||
conn.close()
|
||||
if not row:
|
||||
return None, None, None
|
||||
return row["branch"], row["stage"], row["work_item_id"]
|
||||
except Exception as e: # noqa: BLE001 - never-raise contract
|
||||
logger.warning("reaper: task lookup failed for job %s: %s",
|
||||
job.get("id"), e)
|
||||
return None, None, None
|
||||
|
||||
def _notify_failed(self, job: dict, reason: str) -> None:
|
||||
try:
|
||||
from .notifications import send_telegram
|
||||
send_telegram(
|
||||
f"\U0001f6a8 reaper: job {job.get('id')} ({job.get('agent')}, "
|
||||
f"repo {job.get('repo')}) reaped as FAILED: {reason}"
|
||||
)
|
||||
except Exception as e: # noqa: BLE001 - telegram best-effort
|
||||
logger.warning("reaper: failed-notify telegram error: %s", e)
|
||||
|
||||
def _note_reap(self, job: dict, outcome: str, reason: str) -> None:
|
||||
"""Record + log one successful reap (Р-6 observability)."""
|
||||
self.reaped_total += 1
|
||||
self.last_reaped = {
|
||||
"job_id": job.get("id"),
|
||||
"agent": job.get("agent"),
|
||||
"outcome": outcome,
|
||||
}
|
||||
logger.warning(
|
||||
"reaper: job %s (agent=%s, repo=%s, run_id=%s, pid=%s) reaped -> %s (%s)",
|
||||
job.get("id"), job.get("agent"), job.get("repo"),
|
||||
job.get("run_id"), job.get("pid"), outcome, reason,
|
||||
)
|
||||
|
||||
# -- loop / lifecycle --------------------------------------------------
|
||||
def _tick(self) -> None:
|
||||
try:
|
||||
self.reap_once()
|
||||
finally:
|
||||
self.last_run_ts = datetime.now(timezone.utc).timestamp()
|
||||
|
||||
def _run(self) -> None:
|
||||
logger.info(
|
||||
"JobReaper started (interval=%ss, enabled=%s, dead_ticks=%s, "
|
||||
"max_running_s=%s, lease_reclaim=%s)",
|
||||
self.interval_s, settings.reaper_enabled, settings.reaper_dead_ticks,
|
||||
settings.reaper_max_running_s, settings.lease_reclaim_enabled,
|
||||
)
|
||||
while not self._stop.is_set():
|
||||
try:
|
||||
self._tick()
|
||||
except Exception as e: # noqa: BLE001 - outer never-raise
|
||||
logger.error("JobReaper loop error: %s", e)
|
||||
self._stop.wait(self.interval_s)
|
||||
logger.info("JobReaper stopped")
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start the daemon thread (idempotent: a live thread is a no-op)."""
|
||||
if self._thread and self._thread.is_alive():
|
||||
return
|
||||
self._stop.clear()
|
||||
self._thread = threading.Thread(
|
||||
target=self._run, name="job-reaper", daemon=True
|
||||
)
|
||||
self._thread.start()
|
||||
|
||||
def stop(self, timeout: float = 5.0) -> None:
|
||||
self._stop.set()
|
||||
if self._thread:
|
||||
self._thread.join(timeout=timeout)
|
||||
|
||||
def status(self) -> dict:
|
||||
"""Reaper snapshot for /queue observability (Р-6)."""
|
||||
return {
|
||||
"enabled": settings.reaper_enabled,
|
||||
"interval": self.interval_s,
|
||||
"last_run_ts": self.last_run_ts,
|
||||
"reaped_total": self.reaped_total,
|
||||
"last_reaped": self.last_reaped,
|
||||
"lease_reclaimed_total": self.lease_reclaimed_total,
|
||||
}
|
||||
|
||||
|
||||
# Module-level singleton used by the FastAPI lifespan.
|
||||
reaper = JobReaper()
|
||||
32
src/main.py
32
src/main.py
@@ -60,6 +60,19 @@ async def lifespan(app: FastAPI):
|
||||
if requeued:
|
||||
log.warning(f"Queue-recovery: requeued {requeued} running job(s) after restart")
|
||||
|
||||
# ORCH-065: proactive startup reclaim of dead/stale merge-leases, next to the
|
||||
# queue-recovery above. A lease held by the previous (now dead) process pid is
|
||||
# released at once instead of waiting for the TTL / a foreign acquire so the
|
||||
# next merge is not blocked. Conditional (merge_gate_repos / self-hosting) and
|
||||
# gated by ORCH_LEASE_RECLAIM_ENABLED; never raises.
|
||||
try:
|
||||
from .job_reaper import reclaim_all_stale_leases
|
||||
reclaimed = reclaim_all_stale_leases()
|
||||
if reclaimed:
|
||||
log.warning(f"Startup lease-reclaim: reclaimed {reclaimed} stale merge-lease(s)")
|
||||
except Exception as e:
|
||||
log.warning(f"Startup lease-reclaim skipped: {e}")
|
||||
|
||||
# L-2: rotate old per-run logs at startup (best-effort; never fatal).
|
||||
try:
|
||||
import os as _os
|
||||
@@ -85,13 +98,22 @@ async def lifespan(app: FastAPI):
|
||||
from .reconciler import reconciler
|
||||
reconciler.start()
|
||||
|
||||
# ORCH-065: start the job-reaper LAST (after requeue_running_jobs + the worker
|
||||
# + the reconciler) so its atomic status='running' guard never races the
|
||||
# startup requeue. It reaps zombie jobs and periodically reclaims stale
|
||||
# merge-leases. Kill-switch: ORCH_REAPER_ENABLED.
|
||||
from .job_reaper import reaper
|
||||
reaper.start()
|
||||
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
# Graceful shutdown order mirrors startup in reverse: stop the reconciler
|
||||
# first (it must not enqueue new work while the worker is winding down),
|
||||
# then the worker. Running agents keep going; their jobs are requeued on
|
||||
# next start via queue-recovery if the process dies.
|
||||
# Graceful shutdown order mirrors startup in reverse: stop the reaper
|
||||
# first, then the reconciler (it must not enqueue new work while the
|
||||
# worker is winding down), then the worker. Running agents keep going;
|
||||
# their jobs are requeued on next start via queue-recovery if the
|
||||
# process dies.
|
||||
reaper.stop()
|
||||
reconciler.stop()
|
||||
worker.stop()
|
||||
|
||||
@@ -123,6 +145,7 @@ async def queue():
|
||||
from .db import job_status_counts, recent_jobs
|
||||
from .queue_worker import worker
|
||||
from .reconciler import reconciler
|
||||
from .job_reaper import reaper
|
||||
from . import post_deploy
|
||||
return {
|
||||
"counts": job_status_counts(),
|
||||
@@ -130,6 +153,7 @@ async def queue():
|
||||
"poll_interval": worker.poll_interval,
|
||||
"resilience": worker.status(),
|
||||
"reconcile": reconciler.status(),
|
||||
"reaper": reaper.status(),
|
||||
"post_deploy": post_deploy.status(),
|
||||
"recent": recent_jobs(10),
|
||||
}
|
||||
|
||||
@@ -338,3 +338,150 @@ def release_merge_lease(repo: str, branch: str | None = None) -> None:
|
||||
return
|
||||
except OSError as e:
|
||||
logger.warning("merge-lease release error for %s: %s", repo, e)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ORCH-065: proactive stale/dead merge-lease reclaim (Problem B)
|
||||
# ---------------------------------------------------------------------------
|
||||
def pid_alive(pid) -> bool:
|
||||
"""Return True iff process ``pid`` is alive (``os.kill(pid, 0)`` liveness probe).
|
||||
|
||||
Semantics (ADR-001 Р-2, never-raise):
|
||||
* ``ProcessLookupError`` -> the process is gone -> ``False`` (reclaimable).
|
||||
* ``PermissionError`` -> the pid exists but is owned by another user ->
|
||||
``True`` (alive; conservatively do NOT reclaim).
|
||||
* missing / invalid pid -> ``True`` (conservative: a lease that predates the
|
||||
pid field, or a malformed pid, is NOT reclaimed on the liveness signal —
|
||||
the TTL backstop still catches it).
|
||||
Never raises; any unexpected OS/type error -> conservative ``True``.
|
||||
"""
|
||||
if not pid:
|
||||
return True
|
||||
try:
|
||||
os.kill(int(pid), 0)
|
||||
return True
|
||||
except ProcessLookupError:
|
||||
return False
|
||||
except PermissionError:
|
||||
return True
|
||||
except (OSError, ValueError, TypeError):
|
||||
return True
|
||||
|
||||
|
||||
def _lease_reclaim_applies(repo: str) -> bool:
|
||||
"""Whether proactive lease-reclaim is REAL for ``repo`` (same scope as merge-gate).
|
||||
|
||||
Reuses ``qg.checks._merge_gate_applies`` (``merge_gate_repos`` CSV, else the
|
||||
self-hosting ``orchestrator``) so reclaim and the gate share one predicate
|
||||
(ADR-001 Р-2 / FR-2.4). Imported lazily to avoid an import cycle (qg.checks
|
||||
imports merge_gate lazily inside ``check_branch_mergeable``). Never raises:
|
||||
any error -> ``False`` (no-op, the safe default).
|
||||
"""
|
||||
try:
|
||||
from .qg.checks import _merge_gate_applies
|
||||
return _merge_gate_applies(repo)
|
||||
except Exception as e: # noqa: BLE001 - never-raise contract
|
||||
logger.warning("lease-reclaim applicability check failed for %s: %s", repo, e)
|
||||
return False
|
||||
|
||||
|
||||
def reclaim_stale_lease(repo: str) -> bool:
|
||||
"""Proactively reclaim a dead/stale merge-lease for ``repo`` (ADR-001 Р-2).
|
||||
|
||||
Unlike the lazy TTL reclaim inside ``acquire_merge_lease`` (which only fires
|
||||
when ANOTHER task tries to acquire), this releases the lease as soon as the
|
||||
holder is provably gone — without waiting for the TTL or a foreign acquire:
|
||||
|
||||
* holder pid is dead (``pid_alive`` is False) -> reclaim, OR
|
||||
* lease age >= ``merge_lock_timeout_s`` (TTL) -> reclaim (AC-7).
|
||||
|
||||
A LIVE holder within its TTL is never touched (AC-8 — protects a legitimate
|
||||
in-flight merge). Reclaim is holder-aware (``release_merge_lease(repo,
|
||||
branch=holder)``) so it can never delete a lease a different task acquired in
|
||||
the meantime. Conditional (FR-2.4): real only for ``merge_gate_repos`` /
|
||||
self-hosting; other repos -> no-op. Kill-switch ``lease_reclaim_enabled``.
|
||||
|
||||
Returns True iff a lease was reclaimed. Never raises (AC-9): any read/remove
|
||||
error is logged and swallowed so a single bad lease never kills the reaper
|
||||
thread. Does NOT run any git operation — only the lease file is removed.
|
||||
"""
|
||||
try:
|
||||
if not settings.lease_reclaim_enabled:
|
||||
return False
|
||||
if not _lease_reclaim_applies(repo):
|
||||
return False
|
||||
path = _lease_path(repo)
|
||||
existing = _read_lease(path)
|
||||
if existing is None:
|
||||
return False # no lease (or unreadable -> _read_lease already logged)
|
||||
holder = existing.get("branch")
|
||||
pid = existing.get("pid")
|
||||
age = time.time() - float(existing.get("acquired_at") or 0)
|
||||
dead = not pid_alive(pid)
|
||||
expired = age >= settings.merge_lock_timeout_s
|
||||
if not (dead or expired):
|
||||
return False # live holder within TTL -> protect legitimate merge
|
||||
why = f"dead pid={pid}" if dead else f"stale age={age:.0f}s>=TTL"
|
||||
release_merge_lease(repo, branch=holder)
|
||||
logger.warning(
|
||||
"merge-lease for %s reclaimed proactively (%s, holder=%s)",
|
||||
repo, why, holder,
|
||||
)
|
||||
try:
|
||||
from .notifications import send_telegram
|
||||
send_telegram(
|
||||
f"\U0001f527 merge-lease для {repo} освобождён проактивно "
|
||||
f"({why}, holder={holder})"
|
||||
)
|
||||
except Exception as e: # noqa: BLE001 - telegram best-effort, never fatal
|
||||
logger.warning("lease-reclaim telegram failed for %s: %s", repo, e)
|
||||
return True
|
||||
except Exception as e: # noqa: BLE001 - never-raise contract
|
||||
logger.warning("reclaim_stale_lease unexpected error for %s: %s", repo, e)
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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).
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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):
|
||||
any HTTP/parse error -> ``False`` (conservative: "not known-merged" lets the
|
||||
normal gate re-evaluate rather than silently skipping a real merge).
|
||||
"""
|
||||
try:
|
||||
import httpx
|
||||
owner = settings.gitea_owner
|
||||
headers = {"Authorization": f"token {settings.gitea_token}"}
|
||||
resp = httpx.get(
|
||||
f"{settings.gitea_url}/api/v1/repos/{owner}/{repo}/pulls",
|
||||
params={"state": "all", "head": branch},
|
||||
headers=headers, timeout=_SHORT_TIMEOUT,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
return False
|
||||
for pr in resp.json() or []:
|
||||
if pr.get("merged") is True:
|
||||
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
|
||||
|
||||
@@ -716,6 +716,23 @@ def _check_staging_image_fresh(repo: str, work_item_id: str, branch: str) -> tup
|
||||
return check_staging_image_fresh(repo, work_item_id, branch)
|
||||
|
||||
|
||||
def check_security_gate(repo: str, work_item_id: str, branch: str) -> tuple[bool, str]:
|
||||
"""ORCH-022 security sub-gate (secret-scan + dependency audit) on the
|
||||
deploy-staging -> deploy edge, run FIRST (before merge-gate / image-freshness).
|
||||
|
||||
Thin registry wrapper that delegates to ``security_gate.check_security_gate``
|
||||
(gitleaks offline + pip-audit, write/read-back ``17-security-report.md``). The
|
||||
real logic lives in ``src/security_gate.py`` (leaf module, never-raise,
|
||||
fail-closed on secrets, fail-open degrade for the dep-audit feed); importing it
|
||||
lazily here avoids an import cycle (security_gate imports is_self_hosting_repo
|
||||
from this module). For non-self repos with an empty scope it returns
|
||||
``(True, "security-gate N/A for <repo>")`` so the deploy edge is unchanged for
|
||||
them (AC-13/TC-13).
|
||||
"""
|
||||
from ..security_gate import check_security_gate as _impl
|
||||
return _impl(repo, work_item_id, branch)
|
||||
|
||||
|
||||
# Registry for dynamic lookup by name
|
||||
QG_CHECKS = {
|
||||
"check_analysis_approved": check_analysis_approved,
|
||||
@@ -730,4 +747,5 @@ QG_CHECKS = {
|
||||
"check_staging_status": check_staging_status,
|
||||
"check_branch_mergeable": check_branch_mergeable,
|
||||
"check_staging_image_fresh": _check_staging_image_fresh,
|
||||
"check_security_gate": check_security_gate,
|
||||
}
|
||||
|
||||
689
src/security_gate.py
Normal file
689
src/security_gate.py
Normal file
@@ -0,0 +1,689 @@
|
||||
"""Security-gate core (ORCH-022): secret-scanning + dependency audit before merge.
|
||||
|
||||
Background
|
||||
----------
|
||||
The orchestrator is autonomous: the ``developer`` agent writes code with no human
|
||||
filter. Before a task branch merges into ``main`` there was no automatic check for a
|
||||
leaked secret (key / token / password / private key) or a vulnerable dependency
|
||||
(known CVE). For the self-hosting ``orchestrator`` repo this is acute: one shared
|
||||
prod instance serves every project from a shared DB, so a secret or CVE that slips
|
||||
through one task lands in the prod of all projects (CLAUDE.md §self-hosting, §8).
|
||||
|
||||
This module provides the deterministic (no-LLM) primitives that the quality-gate
|
||||
``check_security_gate`` (src/qg/checks.py) composes on the ``deploy-staging ->
|
||||
deploy`` edge, **FIRST** among the edge sub-gates (BEFORE the merge-gate and
|
||||
image-freshness), immediately before the deployer merges the PR (ADR-001 Р-1):
|
||||
|
||||
* ``scan_secrets`` -> run ``gitleaks`` over ``origin/main..HEAD`` (offline).
|
||||
* ``audit_dependencies`` -> run ``pip-audit`` over ``requirements.txt`` (OSV/PyPI).
|
||||
* ``classify_severity`` -> pure: map a CVE severity to block / warning.
|
||||
* ``compute_verdict`` -> pure: combine findings + thresholds -> the artefact
|
||||
frontmatter fields + a human-readable reason.
|
||||
* ``write_security_report`` / ``parse_security_status`` -> write the
|
||||
``17-security-report.md`` artefact and read its machine verdict back (single
|
||||
source of truth: the gate returns exactly the frontmatter it wrote, AC-8).
|
||||
* ``check_security_gate`` -> the orchestrating entry the QG wrapper delegates to.
|
||||
|
||||
Invariants (ADR-001 §7, never broken):
|
||||
* **Secrets are unconditional** (BR-2): gitleaks is fully offline, so the "a
|
||||
secret always blocks" guarantee does not depend on the network. A secret-scan
|
||||
TOOL error is **fail-closed** (we cannot prove "no secret" -> FAIL).
|
||||
* **Dependency audit is best-effort** (Р-3): an unreachable CVE feed degrades
|
||||
**fail-open + a loud warning** by default (anti-loop, precedent ORCH-061);
|
||||
``security_dep_audit_fail_closed`` flips it to strict.
|
||||
* **never-raise**: any internal error -> ``(False, "<reason>")``; an exception
|
||||
never escapes into ``advance_stage`` (AC-16).
|
||||
* **Self-hosting safety** (AC-19): the gate only reads / scans / writes the
|
||||
artefact. It never calls the deploy hook and never restarts the prod container.
|
||||
|
||||
This module is a **leaf**: it imports only ``config`` / ``git_worktree`` and lazily
|
||||
``qg.checks.is_self_hosting_repo`` / ``notifications``; it never imports
|
||||
``stage_engine``.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from .config import settings
|
||||
from .git_worktree import ensure_worktree, get_worktree_path
|
||||
|
||||
logger = logging.getLogger("orchestrator.security_gate")
|
||||
|
||||
# Bounded git timeout so a hung fetch never wedges the monitor-thread running the
|
||||
# gate (the scan timeout itself comes from settings.security_scan_timeout_s).
|
||||
_GIT_TIMEOUT = 60
|
||||
|
||||
# Severity ranking for the dependency block threshold. UNKNOWN / unrecognised is
|
||||
# intentionally absent -> classified as "warning" (anti-loop, ADR-001 Р-4).
|
||||
_SEVERITY_ORDER = {"LOW": 1, "MEDIUM": 2, "HIGH": 3, "CRITICAL": 4}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Result containers (plain dataclasses, easy to build in tests)
|
||||
# ---------------------------------------------------------------------------
|
||||
@dataclass
|
||||
class SecretScanResult:
|
||||
"""Outcome of :func:`scan_secrets`.
|
||||
|
||||
status:
|
||||
* ``"clean"`` -> no secret found.
|
||||
* ``"found"`` -> ``findings`` lists the confirmed (non-allowlisted) secrets.
|
||||
* ``"error"`` -> the scanner could not run (missing binary / timeout / rc>=2);
|
||||
treated as **fail-closed** by :func:`compute_verdict` (BR-2).
|
||||
"""
|
||||
|
||||
status: str = "clean"
|
||||
findings: list = field(default_factory=list)
|
||||
detail: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class DepAuditResult:
|
||||
"""Outcome of :func:`audit_dependencies`.
|
||||
|
||||
status:
|
||||
* ``"ok"`` -> the audit ran; ``findings`` may be empty or non-empty.
|
||||
* ``"degraded"`` -> the CVE feed was unreachable / the tool failed; **fail-open**
|
||||
by default (ADR-001 Р-3), surfaced as ``deps_audit_degraded: true``.
|
||||
"""
|
||||
|
||||
status: str = "ok"
|
||||
findings: list = field(default_factory=list)
|
||||
detail: str = ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Conditionality (mirrors _merge_gate_applies / image_freshness_applies)
|
||||
# ---------------------------------------------------------------------------
|
||||
def security_gate_applies(repo: str) -> bool:
|
||||
"""Whether the security-gate is REAL for this repo (conditional rollout).
|
||||
|
||||
Mirrors the ORCH-35 / ORCH-43 / ORCH-58 pattern:
|
||||
* ``security_gate_enabled=False`` -> always False (kill-switch; pipeline is
|
||||
1:1 as before ORCH-022 for everyone).
|
||||
* ``security_gate_repos`` (CSV) non-empty -> real only for the listed repos.
|
||||
* empty CSV -> real ONLY for the self-hosting repo (``orchestrator``).
|
||||
Never raises (AC-16): any error -> False (the safe no-op default).
|
||||
"""
|
||||
try:
|
||||
if not settings.security_gate_enabled:
|
||||
return False
|
||||
raw = (settings.security_gate_repos or "").strip()
|
||||
if raw:
|
||||
allowed = {r.strip().lower() for r in raw.split(",") if r.strip()}
|
||||
return (repo or "").strip().lower() in allowed
|
||||
# Lazy import keeps this module a leaf (no qg import at module load).
|
||||
from .qg.checks import is_self_hosting_repo
|
||||
return is_self_hosting_repo(repo)
|
||||
except Exception as e: # noqa: BLE001 - never-raise contract
|
||||
logger.warning("security_gate_applies error for %s: %s", repo, e)
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Secret-scanning (gitleaks, offline) — FR-1 / AC-1..AC-3
|
||||
# ---------------------------------------------------------------------------
|
||||
def _gitleaks_config_path(worktree: str) -> str | None:
|
||||
"""Versioned ``.gitleaks.toml`` at the repo root (BR-13), or None if absent."""
|
||||
cfg = os.path.join(worktree, ".gitleaks.toml")
|
||||
return cfg if os.path.isfile(cfg) else None
|
||||
|
||||
|
||||
def _mask(secret: str) -> str:
|
||||
"""Mask a matched secret so the artefact never re-leaks it verbatim."""
|
||||
s = (secret or "").strip()
|
||||
if len(s) <= 8:
|
||||
return "****"
|
||||
return f"{s[:4]}…{s[-2:]}"
|
||||
|
||||
|
||||
def parse_gitleaks_report(text: str) -> list:
|
||||
"""Pure parser for the gitleaks JSON report -> a list of finding dicts.
|
||||
|
||||
Each finding: ``{"file", "rule", "line", "match"}`` (the match is MASKED).
|
||||
Tolerates an empty / non-JSON / non-list body (returns ``[]``); never raises.
|
||||
"""
|
||||
try:
|
||||
data = json.loads(text or "[]")
|
||||
except (ValueError, TypeError):
|
||||
return []
|
||||
if not isinstance(data, list):
|
||||
return []
|
||||
out = []
|
||||
for item in data:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
out.append(
|
||||
{
|
||||
"file": item.get("File") or item.get("file") or "?",
|
||||
"rule": item.get("RuleID") or item.get("Description") or "secret",
|
||||
"line": item.get("StartLine") or item.get("startLine") or 0,
|
||||
"match": _mask(item.get("Secret") or item.get("Match") or ""),
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def scan_secrets(repo: str, branch: str) -> SecretScanResult:
|
||||
"""Scan ``origin/main..HEAD`` of the task branch for secrets with ``gitleaks``.
|
||||
|
||||
Offline (BR-2): gitleaks rules are local, so the "a secret always blocks"
|
||||
guarantee never depends on the network. Scanning the ``origin/main..HEAD``
|
||||
range covers exactly the commits this task adds (and that will land in
|
||||
``main``), and — because it runs BEFORE the merge-gate rebase — does not blame
|
||||
the task for a secret introduced by a parallel update of ``main`` (ADR-001 Р-1).
|
||||
|
||||
Exit-code contract (07-infra-requirements.md I-1): 0 = clean, 1 = secrets
|
||||
found, >=2 = tool error. A tool error / missing binary / timeout -> ``"error"``
|
||||
(fail-closed downstream). Never raises (AC-16).
|
||||
"""
|
||||
try:
|
||||
wt = ensure_worktree(repo, branch)
|
||||
except Exception as e: # noqa: BLE001 - never-raise contract
|
||||
return SecretScanResult(status="error", detail=f"worktree error: {e}")
|
||||
|
||||
# Refresh origin/main so the origin/main..HEAD range is meaningful. Best-effort:
|
||||
# a fetch failure does not abort the scan (gitleaks still scans whatever range
|
||||
# it can resolve); the scan itself is the security-critical step.
|
||||
try:
|
||||
subprocess.run(
|
||||
["git", "-C", wt, "fetch", "origin", "main"],
|
||||
capture_output=True, timeout=_GIT_TIMEOUT,
|
||||
)
|
||||
except (subprocess.SubprocessError, OSError) as e:
|
||||
logger.warning("scan_secrets: fetch origin/main failed for %s/%s: %s", repo, branch, e)
|
||||
|
||||
report_path = os.path.join(wt, ".gitleaks-report.json")
|
||||
cmd = [
|
||||
"gitleaks", "detect",
|
||||
"--source", wt,
|
||||
"--log-opts", "origin/main..HEAD",
|
||||
"--report-format", "json",
|
||||
"--report-path", report_path,
|
||||
"--exit-code", "1",
|
||||
"--no-banner",
|
||||
]
|
||||
cfg = _gitleaks_config_path(wt)
|
||||
if cfg:
|
||||
cmd += ["--config", cfg]
|
||||
|
||||
timeout = settings.security_scan_timeout_s
|
||||
try:
|
||||
r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
return SecretScanResult(status="error", detail=f"gitleaks timeout after {timeout}s")
|
||||
except FileNotFoundError:
|
||||
# Missing binary -> fail-closed (we cannot prove the branch is secret-free).
|
||||
return SecretScanResult(status="error", detail="gitleaks binary not found")
|
||||
except (subprocess.SubprocessError, OSError) as e:
|
||||
return SecretScanResult(status="error", detail=f"gitleaks error: {e}")
|
||||
finally:
|
||||
# The report file is transient scratch inside the worktree; remove it after
|
||||
# reading so it is never committed/scanned on a later pass.
|
||||
report_text = ""
|
||||
try:
|
||||
if os.path.isfile(report_path):
|
||||
with open(report_path, "r", encoding="utf-8") as f:
|
||||
report_text = f.read()
|
||||
os.remove(report_path)
|
||||
except OSError:
|
||||
report_text = ""
|
||||
|
||||
if r.returncode == 0:
|
||||
return SecretScanResult(status="clean", detail="no secrets found")
|
||||
if r.returncode == 1:
|
||||
findings = parse_gitleaks_report(report_text) or parse_gitleaks_report(r.stdout)
|
||||
if not findings:
|
||||
# rc=1 with no parseable findings -> still treat as found (fail-closed).
|
||||
findings = [{"file": "?", "rule": "secret", "line": 0, "match": "****"}]
|
||||
return SecretScanResult(
|
||||
status="found", findings=findings, detail=f"{len(findings)} secret(s) found"
|
||||
)
|
||||
# rc >= 2 (or any other) -> tool error -> fail-closed.
|
||||
tail = ((r.stderr or "") + (r.stdout or "")).strip()[-200:]
|
||||
return SecretScanResult(status="error", detail=f"gitleaks rc={r.returncode}: {tail}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dependency audit (pip-audit, OSV/PyPI) — FR-2 / AC-4..AC-7
|
||||
# ---------------------------------------------------------------------------
|
||||
def parse_pip_audit_report(text: str) -> list:
|
||||
"""Pure parser for the ``pip-audit -f json`` report -> a list of finding dicts.
|
||||
|
||||
Each finding: ``{"package", "version", "id", "severity", "fix"}``. pip-audit's
|
||||
default JSON rarely carries a CVSS severity (OSV advisories often omit it), so a
|
||||
missing severity is reported as ``"UNKNOWN"`` (classified as a warning, never an
|
||||
auto-block — ADR-001 Р-4 anti-loop). Tolerates both the modern
|
||||
``{"dependencies": [...]}`` shape and a bare list; never raises.
|
||||
"""
|
||||
try:
|
||||
data = json.loads(text or "{}")
|
||||
except (ValueError, TypeError):
|
||||
return []
|
||||
if isinstance(data, dict):
|
||||
deps = data.get("dependencies", data.get("vulnerabilities", []))
|
||||
elif isinstance(data, list):
|
||||
deps = data
|
||||
else:
|
||||
return []
|
||||
out = []
|
||||
for dep in deps or []:
|
||||
if not isinstance(dep, dict):
|
||||
continue
|
||||
name = dep.get("name") or dep.get("package") or "?"
|
||||
version = dep.get("version") or "?"
|
||||
for v in dep.get("vulns", dep.get("vulnerabilities", [])) or []:
|
||||
if not isinstance(v, dict):
|
||||
continue
|
||||
sev = _extract_severity(v)
|
||||
fix = v.get("fix_versions") or v.get("fixed_in") or []
|
||||
aliases = v.get("aliases") or []
|
||||
vuln_id = v.get("id") or (aliases[0] if aliases else "?")
|
||||
out.append(
|
||||
{
|
||||
"package": name,
|
||||
"version": version,
|
||||
"id": vuln_id,
|
||||
"severity": sev,
|
||||
"fix": ", ".join(fix) if isinstance(fix, list) else str(fix),
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _extract_severity(vuln: dict) -> str:
|
||||
"""Best-effort severity extraction from a pip-audit vuln record -> UPPER token.
|
||||
|
||||
pip-audit JSON may carry severity in different shapes depending on the advisory
|
||||
source; when none is present we return ``"UNKNOWN"`` (warning, never a block).
|
||||
"""
|
||||
raw = vuln.get("severity")
|
||||
if isinstance(raw, str) and raw.strip():
|
||||
return raw.strip().upper()
|
||||
if isinstance(raw, list) and raw:
|
||||
first = raw[0]
|
||||
if isinstance(first, dict):
|
||||
val = first.get("severity") or first.get("score") or first.get("type")
|
||||
if val:
|
||||
return str(val).strip().upper()
|
||||
elif first:
|
||||
return str(first).strip().upper()
|
||||
return "UNKNOWN"
|
||||
|
||||
|
||||
def audit_dependencies(repo: str, branch: str) -> DepAuditResult:
|
||||
"""Audit the branch's ``requirements.txt`` for known CVEs with ``pip-audit``.
|
||||
|
||||
The advisory source is OSV/PyPI -> it needs the network. Per ADR-001 Р-3 an
|
||||
unreachable feed / tool failure degrades **fail-open** by default (status
|
||||
``"degraded"``), so a transient network problem on the prod instance never
|
||||
produces a false rollback loop (precedent ORCH-061). The ``"degraded"`` state
|
||||
is surfaced loudly (``deps_audit_degraded: true`` + warning log + Telegram).
|
||||
|
||||
Returns a :class:`DepAuditResult`. Never raises (AC-16).
|
||||
"""
|
||||
try:
|
||||
wt = get_worktree_path(repo, branch)
|
||||
if not os.path.isdir(wt):
|
||||
wt = ensure_worktree(repo, branch)
|
||||
except Exception as e: # noqa: BLE001 - never-raise contract
|
||||
return DepAuditResult(status="degraded", detail=f"worktree error: {e}")
|
||||
|
||||
req = os.path.join(wt, "requirements.txt")
|
||||
if not os.path.isfile(req):
|
||||
# Python-only v1 (A3): no manifest -> nothing to audit (not a degrade).
|
||||
return DepAuditResult(status="ok", detail="no requirements.txt to audit")
|
||||
|
||||
cmd = ["pip-audit", "-r", req, "-f", "json", "--progress-spinner", "off"]
|
||||
timeout = settings.security_scan_timeout_s
|
||||
try:
|
||||
r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
return DepAuditResult(status="degraded", detail=f"pip-audit timeout after {timeout}s")
|
||||
except FileNotFoundError:
|
||||
# Missing binary -> degrade (dep-audit is best-effort, not unconditional).
|
||||
return DepAuditResult(status="degraded", detail="pip-audit binary not found")
|
||||
except (subprocess.SubprocessError, OSError) as e:
|
||||
return DepAuditResult(status="degraded", detail=f"pip-audit error: {e}")
|
||||
|
||||
# pip-audit exits 0 (no vulns) or 1 (vulns found) with valid JSON on stdout. A
|
||||
# network/feed error produces non-JSON output (and often a non-zero rc) -> if
|
||||
# we cannot parse the JSON we degrade fail-open rather than block falsely.
|
||||
out = (r.stdout or "").strip()
|
||||
if not out:
|
||||
if r.returncode == 0:
|
||||
return DepAuditResult(status="ok", detail="no vulnerabilities")
|
||||
tail = (r.stderr or "").strip()[-200:]
|
||||
return DepAuditResult(status="degraded", detail=f"pip-audit no output (rc={r.returncode}): {tail}")
|
||||
try:
|
||||
json.loads(out)
|
||||
except ValueError:
|
||||
tail = (r.stderr or "").strip()[-200:]
|
||||
return DepAuditResult(status="degraded", detail=f"pip-audit feed unavailable: {tail}")
|
||||
|
||||
findings = parse_pip_audit_report(out)
|
||||
return DepAuditResult(status="ok", findings=findings, detail=f"{len(findings)} vuln(s)")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pure classification + verdict (FR-2/FR-3/Р-4) — the core of the unit tests
|
||||
# ---------------------------------------------------------------------------
|
||||
def classify_severity(severity: str, block_threshold: str) -> str:
|
||||
"""Pure: classify a CVE severity against the block threshold -> token.
|
||||
|
||||
Returns ``"block"`` when ``severity >= block_threshold`` in CRITICAL > HIGH >
|
||||
MEDIUM > LOW order, else ``"warning"``. An UNKNOWN / unrecognised severity is
|
||||
ALWAYS ``"warning"`` (never an auto-block — anti-loop, ADR-001 Р-4). Never
|
||||
raises.
|
||||
"""
|
||||
sev = (severity or "").upper().strip()
|
||||
thr = (block_threshold or "HIGH").upper().strip()
|
||||
sev_rank = _SEVERITY_ORDER.get(sev)
|
||||
thr_rank = _SEVERITY_ORDER.get(thr, _SEVERITY_ORDER["HIGH"])
|
||||
if sev_rank is None:
|
||||
return "warning"
|
||||
return "block" if sev_rank >= thr_rank else "warning"
|
||||
|
||||
|
||||
def compute_verdict(
|
||||
secret_result: SecretScanResult,
|
||||
dep_result: DepAuditResult,
|
||||
*,
|
||||
secrets_block: bool,
|
||||
dep_block_severity: str,
|
||||
dep_fail_closed: bool,
|
||||
) -> dict:
|
||||
"""Pure: combine scan results + thresholds into the artefact's machine fields.
|
||||
|
||||
Returns a dict with the frontmatter fields (``security_status``,
|
||||
``secrets_found``, ``deps_blocking``, ``deps_warning``, ``deps_audit_degraded``),
|
||||
a one-line ``reason`` summary, and the categorised finding lists for the body.
|
||||
|
||||
Decision (ADR-001 Р-4):
|
||||
* secret-scan ERROR -> FAIL (fail-closed; BR-2 secrets guarantee is unconditional).
|
||||
* any secret found AND ``secrets_block`` -> FAIL.
|
||||
* any dependency at/over ``dep_block_severity`` -> FAIL (``deps_blocking``).
|
||||
* MEDIUM/LOW/UNKNOWN deps -> warning only (``deps_warning``), never block.
|
||||
* feed degraded -> warning by default; FAIL only when ``dep_fail_closed``.
|
||||
Never raises.
|
||||
"""
|
||||
secret_scan_error = secret_result.status == "error"
|
||||
secret_findings = list(secret_result.findings) if secret_result.status == "found" else []
|
||||
secrets_found = len(secret_findings)
|
||||
|
||||
deps_audit_degraded = dep_result.status == "degraded"
|
||||
blocking_findings = []
|
||||
warning_findings = []
|
||||
for f in dep_result.findings or []:
|
||||
if classify_severity(f.get("severity", "UNKNOWN"), dep_block_severity) == "block":
|
||||
blocking_findings.append(f)
|
||||
else:
|
||||
warning_findings.append(f)
|
||||
|
||||
reasons = []
|
||||
fail = False
|
||||
if secret_scan_error:
|
||||
fail = True
|
||||
reasons.append(f"secret scan error (fail-closed): {secret_result.detail}")
|
||||
if secrets_block and secrets_found > 0:
|
||||
fail = True
|
||||
names = ", ".join(
|
||||
f"{x.get('rule')} in {x.get('file')}:{x.get('line')}" for x in secret_findings
|
||||
)
|
||||
reasons.append(f"{secrets_found} secret(s): {names}")
|
||||
if blocking_findings:
|
||||
fail = True
|
||||
names = ", ".join(
|
||||
f"{x.get('package')} {x.get('version')} {x.get('id')} ({x.get('severity')})"
|
||||
for x in blocking_findings
|
||||
)
|
||||
reasons.append(f"{len(blocking_findings)} blocking CVE(s): {names}")
|
||||
if deps_audit_degraded and dep_fail_closed:
|
||||
fail = True
|
||||
reasons.append(f"dep-audit feed unavailable (fail-closed): {dep_result.detail}")
|
||||
|
||||
status = "FAIL" if fail else "PASS"
|
||||
if reasons:
|
||||
reason = "; ".join(reasons)
|
||||
else:
|
||||
extra = " (dep-audit degraded — warning only)" if deps_audit_degraded else ""
|
||||
reason = f"clean: {secrets_found} secrets, {len(blocking_findings)} blocking CVE(s){extra}"
|
||||
|
||||
return {
|
||||
"security_status": status,
|
||||
"secrets_found": secrets_found,
|
||||
"secret_scan_error": secret_scan_error,
|
||||
"deps_blocking": len(blocking_findings),
|
||||
"deps_warning": len(warning_findings),
|
||||
"deps_audit_degraded": deps_audit_degraded,
|
||||
"reason": reason,
|
||||
"secret_findings": secret_findings,
|
||||
"blocking_findings": blocking_findings,
|
||||
"warning_findings": warning_findings,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Artefact: write the report, read the machine verdict back (FR-3 / AC-8..AC-10)
|
||||
# ---------------------------------------------------------------------------
|
||||
def _report_rel(work_item_id: str) -> str:
|
||||
return f"docs/work-items/{work_item_id}/17-security-report.md"
|
||||
|
||||
|
||||
def _report_path(repo: str, work_item_id: str, branch: str) -> str:
|
||||
"""Absolute path of 17-security-report.md inside the task worktree."""
|
||||
try:
|
||||
wt = get_worktree_path(repo, branch)
|
||||
if not os.path.isdir(wt):
|
||||
wt = ensure_worktree(repo, branch)
|
||||
except Exception: # noqa: BLE001 - never-raise; fall back to shared clone
|
||||
wt = os.path.join(settings.repos_dir, repo)
|
||||
return os.path.join(wt, _report_rel(work_item_id))
|
||||
|
||||
|
||||
def _bool_yaml(v: bool) -> str:
|
||||
return "true" if v else "false"
|
||||
|
||||
|
||||
def render_security_report(work_item_id: str, fields: dict) -> str:
|
||||
"""Pure: render the 17-security-report.md content (frontmatter + body) from the
|
||||
fields produced by :func:`compute_verdict`. Never raises."""
|
||||
def _secret_lines():
|
||||
items = fields.get("secret_findings") or []
|
||||
if not items:
|
||||
return "- None"
|
||||
return "\n".join(
|
||||
f"- `{x.get('file')}:{x.get('line')}` — {x.get('rule')} (match `{x.get('match')}`)"
|
||||
for x in items
|
||||
)
|
||||
|
||||
def _dep_lines(key):
|
||||
items = fields.get(key) or []
|
||||
if not items:
|
||||
return "- None"
|
||||
return "\n".join(
|
||||
f"- `{x.get('package')}=={x.get('version')}` — {x.get('id')} "
|
||||
f"severity={x.get('severity')} fix={x.get('fix') or 'n/a'}"
|
||||
for x in items
|
||||
)
|
||||
|
||||
return (
|
||||
"---\n"
|
||||
f"security_status: {fields.get('security_status', 'FAIL')}\n"
|
||||
f"secrets_found: {int(fields.get('secrets_found', 0))}\n"
|
||||
f"deps_blocking: {int(fields.get('deps_blocking', 0))}\n"
|
||||
f"deps_warning: {int(fields.get('deps_warning', 0))}\n"
|
||||
f"deps_audit_degraded: {_bool_yaml(bool(fields.get('deps_audit_degraded', False)))}\n"
|
||||
"---\n"
|
||||
f"# Security Report — {work_item_id}\n\n"
|
||||
"Детерминированный security-гейт (ORCH-022): secret-scanning (gitleaks, offline) + "
|
||||
"dependency audit (pip-audit). Машинный вердикт читается ТОЛЬКО из frontmatter выше.\n\n"
|
||||
"## Verdict\n"
|
||||
f"{fields.get('reason', '')}\n\n"
|
||||
"## Secrets\n"
|
||||
f"{_secret_lines()}\n\n"
|
||||
"## Dependencies (blocking)\n"
|
||||
f"{_dep_lines('blocking_findings')}\n\n"
|
||||
"## Dependencies (warning)\n"
|
||||
f"{_dep_lines('warning_findings')}\n"
|
||||
)
|
||||
|
||||
|
||||
def write_security_report(repo: str, work_item_id: str, branch: str, fields: dict) -> str:
|
||||
"""Write 17-security-report.md into the task worktree; return its path.
|
||||
|
||||
Best-effort/never-raise: a write error is logged and the path is still returned
|
||||
(the caller's read-back then fails closed). The artefact body is human-readable;
|
||||
the machine verdict lives ONLY in the YAML frontmatter (canon)."""
|
||||
path = _report_path(repo, work_item_id, branch)
|
||||
try:
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write(render_security_report(work_item_id, fields))
|
||||
except OSError as e:
|
||||
logger.error("write_security_report error for %s/%s: %s", repo, work_item_id, e)
|
||||
return path
|
||||
|
||||
|
||||
def parse_security_status(content: str) -> tuple[bool, str]:
|
||||
"""Map a 17-security-report.md body to a quality-gate verdict by reading ONLY
|
||||
the machine-readable ``security_status:`` YAML frontmatter — never the prose.
|
||||
|
||||
Mirrors ``_parse_deploy_status`` / ``_parse_staging_status`` (canon: machine
|
||||
verdict only from frontmatter, AC-8). The negative token (FAIL) is authoritative
|
||||
(checked first). Returns:
|
||||
* ``security_status: PASS`` -> ``(True, "Security status: PASS")``
|
||||
* ``security_status: FAIL`` -> ``(False, "Security status: FAIL")``
|
||||
* missing field / no frontmatter / bad YAML -> ``(False, <reason>)`` (fail-closed
|
||||
on the verdict read, AC-9).
|
||||
"""
|
||||
import yaml
|
||||
|
||||
status = None
|
||||
if content.startswith("---"):
|
||||
parts = content.split("---", 2)
|
||||
if len(parts) >= 3:
|
||||
try:
|
||||
fm = yaml.safe_load(parts[1]) or {}
|
||||
except yaml.YAMLError as e:
|
||||
return False, f"Invalid YAML frontmatter in security report: {e}"
|
||||
if isinstance(fm, dict):
|
||||
status = str(fm.get("security_status", "")).upper().strip()
|
||||
if status == "FAIL":
|
||||
return False, "Security status: FAIL"
|
||||
if status == "PASS":
|
||||
return True, "Security status: PASS"
|
||||
return False, f"No machine-readable security_status in frontmatter (got: {status!r})"
|
||||
|
||||
|
||||
def extract_security_findings(report_path: str) -> str:
|
||||
"""ORCH-046: best-effort verbatim excerpt of the report's finding sections for
|
||||
embedding into the developer's ``task_desc`` on a rollback.
|
||||
|
||||
Pulls the ``## Verdict`` + ``## Secrets`` + ``## Dependencies (blocking)``
|
||||
sections so the developer sees the must-fix substance directly (not just a
|
||||
link). Contract «never raise»: any error / missing file -> ``""`` (the caller
|
||||
then falls back to the reason + link). Mirrors ``review_parse`` defensiveness.
|
||||
"""
|
||||
try:
|
||||
if not os.path.isfile(report_path):
|
||||
return ""
|
||||
with open(report_path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
# Drop the frontmatter; keep the human body.
|
||||
if content.startswith("---"):
|
||||
parts = content.split("---", 2)
|
||||
if len(parts) >= 3:
|
||||
content = parts[2]
|
||||
wanted = ("## Verdict", "## Secrets", "## Dependencies (blocking)")
|
||||
lines = content.splitlines()
|
||||
out = []
|
||||
keep = False
|
||||
for ln in lines:
|
||||
if ln.startswith("## "):
|
||||
keep = any(ln.startswith(w) for w in wanted)
|
||||
if keep:
|
||||
out.append(ln)
|
||||
excerpt = "\n".join(out).strip()
|
||||
return excerpt[:1500]
|
||||
except Exception as e: # noqa: BLE001 - never-raise (ORCH-046 defensive)
|
||||
logger.warning("extract_security_findings error for %s: %s", report_path, e)
|
||||
return ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Orchestrating entry — delegated to by qg.checks.check_security_gate
|
||||
# ---------------------------------------------------------------------------
|
||||
def check_security_gate(repo: str, work_item_id: str, branch: str) -> tuple[bool, str]:
|
||||
"""ORCH-022 security-gate on the deploy-staging -> deploy edge, run FIRST.
|
||||
|
||||
Deterministic, no LLM. Algorithm (ADR-001 Р-1/Р-5):
|
||||
1. Conditionality: ``security_gate_enabled=False`` -> ``(True, "...disabled")``;
|
||||
a repo the gate is not real for -> ``(True, "security-gate N/A for <repo>")``.
|
||||
2. ``scan_secrets`` (offline) + ``audit_dependencies`` (best-effort).
|
||||
3. ``compute_verdict`` -> write ``17-security-report.md`` -> read the verdict
|
||||
BACK via ``parse_security_status`` (single source of truth: the returned
|
||||
verdict == the artefact frontmatter, AC-8).
|
||||
4. FAIL -> ``(False, reason)`` (engine rolls back to ``development``); PASS ->
|
||||
``(True, reason)`` (engine proceeds to the merge-gate).
|
||||
|
||||
A degraded dep-audit on a PASS is surfaced loudly (Telegram + log) without
|
||||
failing the gate (ADR-001 Р-3). Never-raise (AC-16): any internal error ->
|
||||
``(False, "<reason>")``; an exception never escapes into ``advance_stage``.
|
||||
"""
|
||||
try:
|
||||
if not settings.security_gate_enabled:
|
||||
return True, "security-gate disabled"
|
||||
if not security_gate_applies(repo):
|
||||
return True, f"security-gate N/A for {repo}"
|
||||
|
||||
secret_result = scan_secrets(repo, branch)
|
||||
dep_result = audit_dependencies(repo, branch)
|
||||
fields = compute_verdict(
|
||||
secret_result,
|
||||
dep_result,
|
||||
secrets_block=settings.security_secrets_block,
|
||||
dep_block_severity=settings.security_dep_block_severity,
|
||||
dep_fail_closed=settings.security_dep_audit_fail_closed,
|
||||
)
|
||||
|
||||
path = write_security_report(repo, work_item_id, branch, fields)
|
||||
|
||||
# Read the machine verdict back from the artefact we just wrote — so the
|
||||
# returned (bool, reason) is guaranteed == the YAML frontmatter (AC-8).
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
except OSError as e:
|
||||
return False, f"cannot read security report (fail-closed): {e}"
|
||||
ok, _verdict = parse_security_status(content)
|
||||
|
||||
# Surface a degraded dep-audit loudly even when the gate passes (Р-3 / BR-11).
|
||||
if fields.get("deps_audit_degraded"):
|
||||
logger.warning(
|
||||
"security-gate %s/%s: dep-audit DEGRADED (fail-%s): %s",
|
||||
repo, work_item_id,
|
||||
"closed" if settings.security_dep_audit_fail_closed else "open",
|
||||
dep_result.detail,
|
||||
)
|
||||
try:
|
||||
from .notifications import send_telegram
|
||||
send_telegram(
|
||||
f"⚠️ {work_item_id}: dep-audit недоступен фид CVE "
|
||||
f"({dep_result.detail}). "
|
||||
+ ("Гейт fail-closed → FAIL." if settings.security_dep_audit_fail_closed
|
||||
else "Гейт fail-open → warning (секреты проверены оффлайн).")
|
||||
)
|
||||
except Exception as e: # noqa: BLE001 - telegram best-effort
|
||||
logger.warning("security-gate degraded telegram failed: %s", e)
|
||||
|
||||
if ok:
|
||||
logger.info("security-gate passed for %s/%s: %s", repo, work_item_id, fields["reason"])
|
||||
return True, f"security clean ({fields['reason']})"
|
||||
return False, fields["reason"]
|
||||
except Exception as e: # noqa: BLE001 - never-raise contract (AC-16)
|
||||
logger.error("check_security_gate error for %s/%s: %s", repo, branch, e)
|
||||
return False, f"security-gate error: {e}"
|
||||
@@ -34,6 +34,7 @@ from .db import get_db, update_task_stage, enqueue_job
|
||||
from .stages import get_next_stage, get_qg_for_stage, get_agent_for_stage
|
||||
from .git_worktree import get_worktree_path
|
||||
from .review_parse import extract_review_findings, extract_test_failures
|
||||
from .security_gate import extract_security_findings
|
||||
from .qg.checks import QG_CHECKS
|
||||
from . import merge_gate
|
||||
from . import self_deploy
|
||||
@@ -277,6 +278,18 @@ def advance_stage(
|
||||
# event. If it intervenes (defer on busy-lock, or rollback on conflict /
|
||||
# red re-test) it owns the outcome and we return without advancing.
|
||||
if current_stage == "deploy-staging":
|
||||
# --- ORCH-022 security sub-gate (deploy-staging -> deploy edge) -----
|
||||
# Run FIRST among the edge sub-gates (BEFORE the merge-gate and the
|
||||
# image-freshness rebuild): it is cheap (read-only scan) and we want to
|
||||
# fail BEFORE the expensive rebase/rebuild (07-infra I-6). Deterministic:
|
||||
# gitleaks (offline secret-scan) + pip-audit (CVE audit). FAIL -> rollback
|
||||
# to development + developer-retry (cap MAX_DEVELOPER_RETRIES). It owns
|
||||
# the outcome on intervention (mirrors the merge-gate / image-freshness).
|
||||
if _handle_security_gate(
|
||||
task_id, current_stage, repo, work_item_id, branch, agent, result
|
||||
):
|
||||
return result
|
||||
|
||||
if _handle_merge_gate(
|
||||
task_id, current_stage, repo, work_item_id, branch, agent, result
|
||||
):
|
||||
@@ -911,6 +924,93 @@ def _handle_merge_gate_rollback(
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ORCH-022: security sub-gate (secret-scan + dependency audit) on the
|
||||
# deploy-staging -> deploy edge
|
||||
# ---------------------------------------------------------------------------
|
||||
def _handle_security_gate(
|
||||
task_id, current_stage, repo, work_item_id, branch, agent, result: AdvanceResult
|
||||
) -> bool:
|
||||
"""Run check_security_gate on the deploy-staging -> deploy edge (ORCH-022).
|
||||
|
||||
Runs FIRST among the edge sub-gates — BEFORE the merge-gate and the
|
||||
image-freshness rebuild — because it is a cheap read-only scan and we want to
|
||||
fail BEFORE the expensive rebase/rebuild (07-infra I-6). Deterministic (no LLM):
|
||||
gitleaks (offline secret-scan, fail-closed) + pip-audit (CVE audit, fail-open
|
||||
degrade). The machine verdict lives in 17-security-report.md frontmatter.
|
||||
|
||||
Returns True if the gate INTERVENED (the caller must return without advancing):
|
||||
* FAIL (secret found / blocking CVE / fail-closed) -> ROLLBACK to development
|
||||
(+ developer retry, capped by MAX_DEVELOPER_RETRIES). No merge-lease release
|
||||
here: the security-gate runs BEFORE the merge-gate, so the lease is not held
|
||||
yet (distinct from the image-freshness rollback). The verbatim findings are
|
||||
embedded into the developer's task_desc (ORCH-046 pattern, TC-17).
|
||||
Returns False when the gate PASSED (clean, or N/A for a non-self repo with an
|
||||
empty scope) so advance_stage proceeds to the merge-gate.
|
||||
"""
|
||||
passed, reason = _run_qg("check_security_gate", repo, work_item_id, branch)
|
||||
if passed:
|
||||
logger.info(f"Task {task_id}: security-gate passed ({reason})")
|
||||
return False
|
||||
|
||||
result.qg_name = "check_security_gate"
|
||||
result.qg_passed = False
|
||||
result.qg_reason = reason
|
||||
|
||||
update_task_stage(task_id, "development")
|
||||
notify_stage_change(task_id, current_stage, "development")
|
||||
plane_notify_stage(work_item_id, current_stage, "development")
|
||||
result.rolled_back_to = "development"
|
||||
set_issue_in_progress(work_item_id)
|
||||
notify_qg_failure(task_id, current_stage, "check_security_gate", reason)
|
||||
plane_add_comment(
|
||||
work_item_id,
|
||||
f"❌ Security-гейт провален ({reason}). Откат на development. "
|
||||
f"Developer нужен для фикса (секреты/уязвимые зависимости).",
|
||||
author="deployer",
|
||||
)
|
||||
retry_count = _developer_retry_count(task_id)
|
||||
if retry_count < MAX_DEVELOPER_RETRIES:
|
||||
# ORCH-046: embed the verbatim findings into task_desc so the developer
|
||||
# agent sees the must-fix substance directly (not just a link).
|
||||
# extract_security_findings never raises; "" -> graceful link-only fallback.
|
||||
report_ref = f"docs/work-items/{work_item_id}/17-security-report.md"
|
||||
report_path = os.path.join(get_worktree_path(repo, branch), report_ref)
|
||||
findings = extract_security_findings(report_path)
|
||||
head = (
|
||||
f"Work item: {work_item_id}\nRepo: {repo}\nBranch: {branch}\n"
|
||||
f"Stage: development\nNote: Security-гейт провален "
|
||||
f"(attempt {retry_count + 1}/{MAX_DEVELOPER_RETRIES}). "
|
||||
f"Причина: {reason}."
|
||||
)
|
||||
if findings:
|
||||
task_desc = (
|
||||
f"{head}\nFindings:\n{findings}\n"
|
||||
f"Полный контекст: {report_ref}"
|
||||
)
|
||||
else:
|
||||
task_desc = f"{head} Fix findings in {report_ref}"
|
||||
new_job = enqueue_job("developer", repo, task_desc, task_id=task_id)
|
||||
result.enqueued_agent = "developer"
|
||||
result.enqueued_job_id = new_job
|
||||
logger.info(
|
||||
f"Task {task_id}: security-gate FAILED, enqueued developer (job_id={new_job})"
|
||||
)
|
||||
else:
|
||||
set_issue_blocked(work_item_id)
|
||||
send_telegram(
|
||||
f"\U0001f6a8 {work_item_id}: Security-гейт still failing after "
|
||||
f"{MAX_DEVELOPER_RETRIES} developer retries ({reason}). "
|
||||
f"Manual intervention needed."
|
||||
)
|
||||
result.alerted = True
|
||||
logger.error(
|
||||
f"Task {task_id}: security-gate FAILED, rolled back deploy-staging -> "
|
||||
f"development ({reason})"
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ORCH-058: staging-image freshness sub-gate on the deploy-staging -> deploy edge
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -165,3 +165,83 @@ def test_staging_infra_tolerance_env_override_true(monkeypatch):
|
||||
"""The field is read verbatim from its ORCH_* env var."""
|
||||
monkeypatch.setenv("ORCH_STAGING_INFRA_TOLERANCE_ENABLED", "true")
|
||||
assert Settings().staging_infra_tolerance_enabled is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ORCH-065 / TC-20: reaper_* + lease_reclaim_* settings defaults + env override.
|
||||
# ---------------------------------------------------------------------------
|
||||
_REAPER_ENV = (
|
||||
"ORCH_REAPER_ENABLED",
|
||||
"ORCH_REAPER_INTERVAL_S",
|
||||
"ORCH_REAPER_DEAD_TICKS",
|
||||
"ORCH_REAPER_MAX_RUNNING_S",
|
||||
"ORCH_LEASE_RECLAIM_ENABLED",
|
||||
)
|
||||
|
||||
|
||||
def test_reaper_settings_defaults(monkeypatch):
|
||||
"""TC-20 / §5: documented defaults when no env is set."""
|
||||
for name in _REAPER_ENV:
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
s = Settings()
|
||||
assert s.reaper_enabled is True
|
||||
assert s.reaper_interval_s == 60
|
||||
assert s.reaper_dead_ticks == 2
|
||||
assert s.reaper_max_running_s == 3600
|
||||
assert s.lease_reclaim_enabled is True
|
||||
|
||||
|
||||
def test_reaper_settings_env_override(monkeypatch):
|
||||
"""TC-20 / §5 / AC-14: each field is read from its ORCH_* env var."""
|
||||
monkeypatch.setenv("ORCH_REAPER_ENABLED", "false")
|
||||
monkeypatch.setenv("ORCH_REAPER_INTERVAL_S", "30")
|
||||
monkeypatch.setenv("ORCH_REAPER_DEAD_TICKS", "5")
|
||||
monkeypatch.setenv("ORCH_REAPER_MAX_RUNNING_S", "1200")
|
||||
monkeypatch.setenv("ORCH_LEASE_RECLAIM_ENABLED", "false")
|
||||
s = Settings()
|
||||
assert s.reaper_enabled is False
|
||||
assert s.reaper_interval_s == 30
|
||||
assert s.reaper_dead_ticks == 5
|
||||
assert s.reaper_max_running_s == 1200
|
||||
assert s.lease_reclaim_enabled is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ORCH-065 / TC-19: contracts unchanged — no new stages / QG checks; the
|
||||
# check_branch_mergeable signature is intact (AC-13).
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc19_stage_transitions_unchanged():
|
||||
"""No new pipeline stage was introduced by ORCH-065."""
|
||||
from src.stages import STAGE_TRANSITIONS
|
||||
assert set(STAGE_TRANSITIONS) == {
|
||||
"created", "analysis", "architecture", "development", "review",
|
||||
"testing", "deploy-staging", "deploy", "done",
|
||||
}
|
||||
|
||||
|
||||
def test_tc19_qg_checks_registry_unchanged():
|
||||
"""No new quality-gate check was added to the registry by ORCH-065."""
|
||||
from src.qg.checks import QG_CHECKS
|
||||
assert set(QG_CHECKS) == {
|
||||
"check_analysis_approved",
|
||||
"check_analysis_complete",
|
||||
"check_architecture_done",
|
||||
"check_ci_green",
|
||||
"check_review_approved",
|
||||
"check_tests_passed",
|
||||
"check_reviewer_verdict",
|
||||
"check_tests_local",
|
||||
"check_deploy_status",
|
||||
"check_staging_status",
|
||||
"check_branch_mergeable",
|
||||
"check_staging_image_fresh",
|
||||
"check_security_gate",
|
||||
}
|
||||
|
||||
|
||||
def test_tc19_check_branch_mergeable_signature_intact():
|
||||
"""check_branch_mergeable still takes exactly (repo, work_item_id, branch)."""
|
||||
import inspect
|
||||
from src.qg.checks import check_branch_mergeable
|
||||
params = list(inspect.signature(check_branch_mergeable).parameters)
|
||||
assert params == ["repo", "work_item_id", "branch"]
|
||||
|
||||
@@ -101,6 +101,7 @@ def test_tc05_no_approve_does_not_call_prod_hook(monkeypatch):
|
||||
stage_engine, "QG_CHECKS",
|
||||
{**stage_engine.QG_CHECKS,
|
||||
"check_staging_status": _pass,
|
||||
"check_security_gate": _pass,
|
||||
"check_branch_mergeable": _pass,
|
||||
"check_staging_image_fresh": _pass},
|
||||
)
|
||||
|
||||
388
tests/test_job_reaper.py
Normal file
388
tests/test_job_reaper.py
Normal file
@@ -0,0 +1,388 @@
|
||||
"""ORCH-065: job-reaper unit tests (TC-01..TC-08, TC-21).
|
||||
|
||||
The reaper never spawns claude; we drive the DB directly (a 'running' jobs row +
|
||||
optional agent_runs exit_code/pid) and assert the terminal flip + side-effects.
|
||||
``os.kill`` liveness is monkeypatched so a 'dead'/'alive' pid is deterministic.
|
||||
"""
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
# Override env before importing app modules (same convention as test_queue.py).
|
||||
os.environ["ORCH_DB_PATH"] = os.path.join(tempfile.gettempdir(), "test_orch_reaper.db")
|
||||
os.environ["ORCH_REPOS_DIR"] = tempfile.gettempdir()
|
||||
os.environ["ORCH_GITEA_TOKEN"] = "test-token"
|
||||
os.environ["ORCH_PLANE_API_TOKEN"] = "test-token"
|
||||
|
||||
import src.db as db
|
||||
from src.db import init_db, get_db, enqueue_job, get_job
|
||||
import src.job_reaper as jr
|
||||
from src.job_reaper import JobReaper
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fresh_db(tmp_path, monkeypatch):
|
||||
dbfile = tmp_path / "reaper.db"
|
||||
monkeypatch.setattr(db.settings, "db_path", str(dbfile))
|
||||
init_db()
|
||||
yield
|
||||
|
||||
|
||||
# --- helpers ----------------------------------------------------------------
|
||||
def _make_running_job(agent="developer", repo="orchestrator", task_id=None,
|
||||
pid=None, age_s=0, attempts=0, max_attempts=2,
|
||||
run_id=None, exit_code=None, finished_age_s=600):
|
||||
"""Insert a job already in 'running' with the given pid/age/attempts.
|
||||
|
||||
started_at is back-dated by ``age_s`` seconds so running_age_s reflects it.
|
||||
When ``exit_code`` is given an agent_runs row is created and linked (Tier-2);
|
||||
its ``finished_at`` is back-dated by ``finished_age_s`` seconds so the
|
||||
Tier-2 finalization grace (``reaper_finalize_grace_s``, default 300) is
|
||||
satisfied by default — pass a small ``finished_age_s`` to exercise the
|
||||
"monitor may still be finalizing" deferral.
|
||||
"""
|
||||
conn = get_db()
|
||||
if run_id is None and exit_code is not None:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO agent_runs (task_id, agent, finished_at, exit_code) "
|
||||
"VALUES (?, ?, datetime('now', ?), ?)",
|
||||
(task_id, agent, f"-{int(finished_age_s)} seconds", exit_code),
|
||||
)
|
||||
run_id = cur.lastrowid
|
||||
cur = conn.execute(
|
||||
"INSERT INTO jobs (agent, repo, task_id, status, attempts, max_attempts, "
|
||||
"run_id, pid, started_at) "
|
||||
"VALUES (?, ?, ?, 'running', ?, ?, ?, ?, datetime('now', ?))",
|
||||
(agent, repo, task_id, attempts, max_attempts, run_id, pid,
|
||||
f"-{int(age_s)} seconds"),
|
||||
)
|
||||
job_id = cur.lastrowid
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return job_id
|
||||
|
||||
|
||||
def _make_task(repo="orchestrator", branch="feature/x", stage="development",
|
||||
work_item_id="ORCH-1"):
|
||||
conn = get_db()
|
||||
cur = conn.execute(
|
||||
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage) "
|
||||
"VALUES (?, ?, ?, ?, ?)",
|
||||
(work_item_id, work_item_id, repo, branch, stage),
|
||||
)
|
||||
tid = cur.lastrowid
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return tid
|
||||
|
||||
|
||||
def _dead_pid(monkeypatch):
|
||||
"""Force merge_gate.pid_alive -> False (process gone) for the reaper."""
|
||||
import src.merge_gate as mg
|
||||
monkeypatch.setattr(mg, "pid_alive", lambda pid: False)
|
||||
|
||||
|
||||
def _alive_pid(monkeypatch):
|
||||
import src.merge_gate as mg
|
||||
monkeypatch.setattr(mg, "pid_alive", lambda pid: True)
|
||||
|
||||
|
||||
# --- TC-01: dead executor -> reaped without process restart -----------------
|
||||
def test_tc01_dead_pid_reaped_to_queued(monkeypatch):
|
||||
_dead_pid(monkeypatch)
|
||||
jid = _make_running_job(pid=999999, attempts=0, max_attempts=2)
|
||||
r = JobReaper()
|
||||
r.reap_once() # tick 1 (streak=1, dead_ticks default 2 -> not yet)
|
||||
assert get_job(jid)["status"] == "running"
|
||||
r.reap_once() # tick 2 -> reaped
|
||||
assert get_job(jid)["status"] == "queued"
|
||||
assert r.reaped_total == 1
|
||||
assert r.last_reaped["job_id"] == jid
|
||||
|
||||
|
||||
# --- TC-02: live agent within timeout is NEVER reaped -----------------------
|
||||
def test_tc02_alive_pid_never_reaped(monkeypatch):
|
||||
_alive_pid(monkeypatch)
|
||||
jid = _make_running_job(pid=4321, age_s=10)
|
||||
r = JobReaper()
|
||||
for _ in range(5):
|
||||
r.reap_once()
|
||||
assert get_job(jid)["status"] == "running"
|
||||
assert r.reaped_total == 0
|
||||
|
||||
|
||||
def test_tc02_alive_within_max_running_not_reaped(monkeypatch):
|
||||
_alive_pid(monkeypatch)
|
||||
monkeypatch.setattr(db.settings, "reaper_max_running_s", 3600)
|
||||
jid = _make_running_job(pid=4321, age_s=1800) # < ceiling, alive
|
||||
r = JobReaper()
|
||||
r.reap_once()
|
||||
assert get_job(jid)["status"] == "running"
|
||||
|
||||
|
||||
# --- TC-03: zombie only after reaper_dead_ticks consecutive ticks -----------
|
||||
def test_tc03_requires_consecutive_dead_ticks(monkeypatch):
|
||||
monkeypatch.setattr(db.settings, "reaper_dead_ticks", 3)
|
||||
import src.merge_gate as mg
|
||||
# Dead, dead, ALIVE (resets), dead, dead, dead -> reaped only on the 6th tick.
|
||||
seq = iter([False, False, True, False, False, False])
|
||||
monkeypatch.setattr(mg, "pid_alive", lambda pid: next(seq))
|
||||
jid = _make_running_job(pid=999998)
|
||||
r = JobReaper()
|
||||
for _ in range(5):
|
||||
r.reap_once()
|
||||
assert get_job(jid)["status"] == "running"
|
||||
r.reap_once() # 6th tick: third CONSECUTIVE dead -> reaped
|
||||
assert get_job(jid)["status"] == "queued"
|
||||
|
||||
|
||||
# --- TC-04: backstop ceiling reaps even when liveness is unknown ------------
|
||||
def test_tc04_backstop_ceiling(monkeypatch):
|
||||
_alive_pid(monkeypatch) # liveness says "alive", but age exceeds the ceiling
|
||||
monkeypatch.setattr(db.settings, "reaper_max_running_s", 100)
|
||||
jid = _make_running_job(pid=4321, age_s=500)
|
||||
r = JobReaper()
|
||||
r.reap_once()
|
||||
assert get_job(jid)["status"] == "queued"
|
||||
assert r.reaped_total == 1
|
||||
|
||||
|
||||
def test_tc04_backstop_no_pid(monkeypatch):
|
||||
monkeypatch.setattr(db.settings, "reaper_max_running_s", 100)
|
||||
jid = _make_running_job(pid=None, age_s=500)
|
||||
r = JobReaper()
|
||||
r.reap_once()
|
||||
assert get_job(jid)["status"] == "queued"
|
||||
|
||||
|
||||
# --- TC-05: correct outcome by exit_code (Tier-2) ---------------------------
|
||||
def _gate(monkeypatch, green: bool):
|
||||
"""Force the reaper's READ-ONLY gate pre-evaluation to green/red."""
|
||||
monkeypatch.setattr(
|
||||
JobReaper, "_gate_is_green",
|
||||
lambda self, stage, job, branch, wid: green,
|
||||
)
|
||||
|
||||
|
||||
def test_tc05_exit0_gate_green_done(monkeypatch):
|
||||
# A developer job runs to LEAVE the 'architecture' stage (-> 'development').
|
||||
tid = _make_task(stage="architecture")
|
||||
jid = _make_running_job(agent="developer", task_id=tid, exit_code=0)
|
||||
_gate(monkeypatch, green=True)
|
||||
# gate green -> the claim flips 'done' FIRST, then the advance runs.
|
||||
import src.agents.launcher as L
|
||||
monkeypatch.setattr(
|
||||
L.launcher, "_try_advance_stage",
|
||||
lambda run_id, agent, repo, branch: db.update_task_stage(tid, "development"),
|
||||
)
|
||||
r = JobReaper()
|
||||
r.reap_once()
|
||||
assert get_job(jid)["status"] == "done"
|
||||
|
||||
|
||||
def test_tc05_exit0_gate_red_requeues(monkeypatch):
|
||||
tid = _make_task(stage="architecture")
|
||||
jid = _make_running_job(agent="developer", task_id=tid, exit_code=0,
|
||||
attempts=0, max_attempts=2)
|
||||
_gate(monkeypatch, green=False) # read-only pre-eval says red
|
||||
# The advance path must NEVER run when the gate is red (claim-before-act).
|
||||
import src.agents.launcher as L
|
||||
called = []
|
||||
monkeypatch.setattr(L.launcher, "_try_advance_stage",
|
||||
lambda run_id, agent, repo, branch: called.append(1))
|
||||
r = JobReaper()
|
||||
r.reap_once()
|
||||
assert get_job(jid)["status"] == "queued" # exit0 but gate red -> not 'done'
|
||||
assert not called, "no advance/side-effects on a red gate"
|
||||
|
||||
|
||||
def test_tc05_exit0_already_advanced_done_no_side_effects(monkeypatch):
|
||||
# Stage already past the developer candidate set -> idempotent clean 'done'
|
||||
# with NO advance call (the monitor already advanced before dying).
|
||||
tid = _make_task(stage="development") # developer's candidate is 'architecture'
|
||||
jid = _make_running_job(agent="developer", task_id=tid, exit_code=0)
|
||||
import src.agents.launcher as L
|
||||
called = []
|
||||
monkeypatch.setattr(L.launcher, "_try_advance_stage",
|
||||
lambda run_id, agent, repo, branch: called.append(1))
|
||||
r = JobReaper()
|
||||
r.reap_once()
|
||||
assert get_job(jid)["status"] == "done"
|
||||
assert not called, "already-advanced reap must not re-advance"
|
||||
|
||||
|
||||
def test_tc05_nonzero_exit_requeue_then_failed(monkeypatch):
|
||||
sent = []
|
||||
monkeypatch.setattr(jr, "JobReaper", JobReaper)
|
||||
tid = _make_task(stage="development")
|
||||
jid = _make_running_job(agent="developer", task_id=tid, exit_code=1,
|
||||
attempts=1, max_attempts=2)
|
||||
r = JobReaper()
|
||||
import src.notifications as notif
|
||||
monkeypatch.setattr(notif, "send_telegram", lambda *a, **k: sent.append(a))
|
||||
r.reap_once() # attempts(1) < max(2) -> queued
|
||||
assert get_job(jid)["status"] == "queued"
|
||||
|
||||
# Now exhaust the budget.
|
||||
jid2 = _make_running_job(agent="developer", task_id=tid, exit_code=1,
|
||||
attempts=2, max_attempts=2)
|
||||
r.reap_once()
|
||||
assert get_job(jid2)["status"] == "failed"
|
||||
assert sent, "failed reap must send a Telegram alert"
|
||||
|
||||
|
||||
# --- TC-05b: Tier-2 finalization grace (live monitor still finalizing) -------
|
||||
def test_tc05_tier2_within_grace_not_reaped(monkeypatch):
|
||||
"""exit_code freshly recorded -> a LIVE monitor may still be finalizing.
|
||||
|
||||
The reaper must NOT reap it within ``reaper_finalize_grace_s`` (FR-1.3/AC-3:
|
||||
a live finalizing monitor — git push / PR / Plane comments — is never reaped,
|
||||
no dup advance / enqueue).
|
||||
"""
|
||||
monkeypatch.setattr(db.settings, "reaper_finalize_grace_s", 300)
|
||||
tid = _make_task(stage="architecture")
|
||||
# exit_code recorded only 5s ago -> still inside the finalization grace.
|
||||
jid = _make_running_job(agent="developer", task_id=tid, exit_code=0,
|
||||
finished_age_s=5)
|
||||
import src.agents.launcher as L
|
||||
called = []
|
||||
monkeypatch.setattr(L.launcher, "_try_advance_stage",
|
||||
lambda run_id, agent, repo, branch: called.append(1))
|
||||
r = JobReaper()
|
||||
r.reap_once()
|
||||
assert get_job(jid)["status"] == "running" # deferred, NOT reaped
|
||||
assert r.reaped_total == 0
|
||||
assert not called, "a live finalizing monitor must not be advanced by the reaper"
|
||||
|
||||
|
||||
def test_tc05_tier2_after_grace_reaped(monkeypatch):
|
||||
"""Once exit_code has been recorded longer than the grace, the monitor is
|
||||
genuinely dead and the Tier-2 reap proceeds."""
|
||||
monkeypatch.setattr(db.settings, "reaper_finalize_grace_s", 300)
|
||||
tid = _make_task(stage="architecture")
|
||||
jid = _make_running_job(agent="developer", task_id=tid, exit_code=0,
|
||||
finished_age_s=600) # well past the grace
|
||||
_gate(monkeypatch, green=True)
|
||||
import src.agents.launcher as L
|
||||
monkeypatch.setattr(
|
||||
L.launcher, "_try_advance_stage",
|
||||
lambda run_id, agent, repo, branch: db.update_task_stage(tid, "development"),
|
||||
)
|
||||
r = JobReaper()
|
||||
r.reap_once()
|
||||
assert get_job(jid)["status"] == "done"
|
||||
|
||||
|
||||
def test_tc05_tier2_lost_claim_no_side_effects(monkeypatch):
|
||||
"""claim-BEFORE-act: when another actor (a late monitor / startup requeue)
|
||||
moves the row out of 'running' AFTER the reaper read it but BEFORE the atomic
|
||||
claim, the reaper's claim loses (rowcount==0) and it performs NO advance side
|
||||
effects (no dup advance / dup enqueue) — ADR-001 Р-1."""
|
||||
monkeypatch.setattr(db.settings, "reaper_finalize_grace_s", 0)
|
||||
tid = _make_task(stage="architecture")
|
||||
jid = _make_running_job(agent="developer", task_id=tid, exit_code=0,
|
||||
finished_age_s=10)
|
||||
import src.agents.launcher as L
|
||||
called = []
|
||||
monkeypatch.setattr(L.launcher, "_try_advance_stage",
|
||||
lambda run_id, agent, repo, branch: called.append(1))
|
||||
|
||||
# The read-only gate pre-eval reports green, but the row is concurrently
|
||||
# claimed by someone else right before the reaper's atomic claim runs.
|
||||
def green_then_steal(self, stage, job, branch, wid):
|
||||
db.requeue_running_jobs() # another actor wins the 'running' row first
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(JobReaper, "_gate_is_green", green_then_steal)
|
||||
r = JobReaper()
|
||||
r.reap_once()
|
||||
# Reaper lost the atomic claim -> no advance, no double work. The row stays
|
||||
# where the winner left it ('queued'), not flipped to 'done' by the reaper.
|
||||
assert not called, "reaper that lost the claim must not advance/enqueue"
|
||||
assert get_job(jid)["status"] == "queued"
|
||||
assert r.reaped_total == 0
|
||||
|
||||
|
||||
# --- TC-06: atomicity — reaper vs requeue_running_jobs (status guard) --------
|
||||
def test_tc06_atomic_no_double_reap(monkeypatch):
|
||||
_dead_pid(monkeypatch)
|
||||
monkeypatch.setattr(db.settings, "reaper_dead_ticks", 1)
|
||||
jid = _make_running_job(pid=999997, attempts=0, max_attempts=2)
|
||||
# Simulate the startup requeue winning the row first.
|
||||
n = db.requeue_running_jobs()
|
||||
assert n == 1
|
||||
assert get_job(jid)["status"] == "queued"
|
||||
# The reaper now scans: the row is no longer 'running' -> reap_running_job's
|
||||
# WHERE status='running' guard yields rowcount 0 -> no second processing.
|
||||
r = JobReaper()
|
||||
r.reap_once()
|
||||
assert get_job(jid)["status"] == "queued"
|
||||
assert r.reaped_total == 0
|
||||
|
||||
|
||||
def test_tc06_reap_running_job_guard_returns_false_when_not_running():
|
||||
jid = enqueue_job("developer", "orchestrator") # status 'queued', not running
|
||||
assert db.reap_running_job(jid, "done") is False
|
||||
assert get_job(jid)["status"] == "queued"
|
||||
|
||||
|
||||
# --- TC-07: kill-switch reaper_enabled=False -> no-op -----------------------
|
||||
def test_tc07_kill_switch(monkeypatch):
|
||||
_dead_pid(monkeypatch)
|
||||
monkeypatch.setattr(db.settings, "reaper_enabled", False)
|
||||
monkeypatch.setattr(db.settings, "lease_reclaim_enabled", False)
|
||||
jid = _make_running_job(pid=999996, age_s=99999)
|
||||
r = JobReaper()
|
||||
for _ in range(3):
|
||||
r.reap_once()
|
||||
assert get_job(jid)["status"] == "running"
|
||||
assert r.reaped_total == 0
|
||||
|
||||
|
||||
# --- TC-08: never-raise — a DB/OS error in one tick does not propagate -------
|
||||
def test_tc08_never_raise_isolates_per_job(monkeypatch):
|
||||
_dead_pid(monkeypatch)
|
||||
monkeypatch.setattr(db.settings, "reaper_dead_ticks", 1)
|
||||
good = _make_running_job(pid=111, attempts=0, max_attempts=2)
|
||||
bad = _make_running_job(pid=222, attempts=0, max_attempts=2)
|
||||
|
||||
r = JobReaper()
|
||||
orig = r._reap_job
|
||||
|
||||
def boom(job):
|
||||
if job["id"] == bad:
|
||||
raise RuntimeError("simulated per-job failure")
|
||||
return orig(job)
|
||||
|
||||
monkeypatch.setattr(r, "_reap_job", boom)
|
||||
# Must not raise despite the bad job blowing up.
|
||||
r.reap_once()
|
||||
# The good job is still reaped; the bad one is isolated (stays running).
|
||||
assert get_job(good)["status"] == "queued"
|
||||
assert get_job(bad)["status"] == "running"
|
||||
|
||||
|
||||
def test_tc08_reap_once_outer_never_raises(monkeypatch):
|
||||
monkeypatch.setattr(jr, "get_running_jobs",
|
||||
lambda: (_ for _ in ()).throw(RuntimeError("db down")))
|
||||
r = JobReaper()
|
||||
# reap_once swallows... actually get_running_jobs is iterated in the for; the
|
||||
# _tick wrapper guarantees the loop never dies. Assert _tick is safe.
|
||||
r._tick()
|
||||
assert r.last_run_ts is not None
|
||||
|
||||
|
||||
# --- TC-21: startup lease-reclaim + reaper start/stop smoke -----------------
|
||||
def test_tc21_reaper_start_stop_smoke():
|
||||
r = JobReaper(interval_s=0.05)
|
||||
r.start()
|
||||
assert r._thread is not None and r._thread.is_alive()
|
||||
r.stop(timeout=2)
|
||||
assert not r._thread.is_alive()
|
||||
|
||||
|
||||
def test_tc21_reclaim_all_stale_leases_callable(monkeypatch):
|
||||
# No lease files present -> 0 reclaimed, never raises (registration smoke).
|
||||
monkeypatch.setattr(db.settings, "lease_reclaim_enabled", True)
|
||||
assert jr.reclaim_all_stale_leases() == 0
|
||||
@@ -11,6 +11,7 @@ import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
# Env before importing app modules (same convention as the other suites).
|
||||
@@ -299,3 +300,85 @@ def test_tc11_release_missing_is_noop(lease_dir):
|
||||
# Releasing a non-existent lease never raises.
|
||||
merge_gate.release_merge_lease("orchestrator", "feature/none")
|
||||
merge_gate.release_merge_lease("orchestrator") # force form
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ORCH-065 / TC-16: idempotent merge finalization — pr_already_merged guard.
|
||||
# ---------------------------------------------------------------------------
|
||||
class _FakeResp:
|
||||
def __init__(self, status_code, payload):
|
||||
self.status_code = status_code
|
||||
self._payload = payload
|
||||
|
||||
def json(self):
|
||||
return self._payload
|
||||
|
||||
|
||||
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)."""
|
||||
monkeypatch.setattr(
|
||||
httpx, "get",
|
||||
lambda *a, **k: _FakeResp(200, [{"number": 7, "merged": True}]),
|
||||
)
|
||||
assert merge_gate.pr_already_merged("orchestrator", "feature/x") is True
|
||||
|
||||
|
||||
def test_tc16_pr_open_not_merged_false(monkeypatch):
|
||||
"""An open / not-yet-merged PR -> False (the normal merge path proceeds)."""
|
||||
monkeypatch.setattr(
|
||||
httpx, "get",
|
||||
lambda *a, **k: _FakeResp(200, [{"number": 7, "merged": False}]),
|
||||
)
|
||||
assert merge_gate.pr_already_merged("orchestrator", "feature/x") is False
|
||||
|
||||
|
||||
def test_tc16_pr_no_pr_false(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
httpx, "get", lambda *a, **k: _FakeResp(200, []),
|
||||
)
|
||||
assert merge_gate.pr_already_merged("orchestrator", "feature/x") is False
|
||||
|
||||
|
||||
def test_tc16_pr_already_merged_never_raises(monkeypatch):
|
||||
"""Any HTTP/parse error -> False (conservative), never an exception (AC-9)."""
|
||||
def boom(*a, **k):
|
||||
raise RuntimeError("gitea down")
|
||||
|
||||
monkeypatch.setattr(httpx, "get", boom)
|
||||
assert merge_gate.pr_already_merged("orchestrator", "feature/x") is False
|
||||
|
||||
|
||||
def test_tc16_pr_non_200_false(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
httpx, "get", lambda *a, **k: _FakeResp(500, None),
|
||||
)
|
||||
assert merge_gate.pr_already_merged("orchestrator", "feature/x") is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ORCH-065 / TC-16 (wiring): the merge path consults the guard.
|
||||
#
|
||||
# pr_already_merged is consulted by the actual merge actor — the deployer agent
|
||||
# (webhooks/gitea.py: "the deployer merges the PR at the START of its run"). The
|
||||
# `deploy` stage can be re-driven by the job-reaper, so the deployer prompt MUST
|
||||
# instruct an idempotent pre-merge consult of pr_already_merged (ADR-001 Р-3 /
|
||||
# README / CHANGELOG). This test fails if that wiring regresses, so the guard can
|
||||
# never silently become dead code again while the docs claim it is consulted.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc16_deployer_prompt_consults_guard():
|
||||
"""The deployer prompt (merge path) wires the idempotent merge guard."""
|
||||
repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
prompt_path = os.path.join(repo_root, ".openclaw", "agents", "deployer.md")
|
||||
with open(prompt_path, "r", encoding="utf-8") as f:
|
||||
prompt = f.read()
|
||||
|
||||
# The guard function is named and the prompt instructs consulting it BEFORE merge.
|
||||
assert "pr_already_merged" in prompt, "deployer prompt must name the guard"
|
||||
lowered = prompt.lower()
|
||||
assert "before" in lowered and "merge" in lowered, (
|
||||
"deployer prompt must instruct consulting the guard BEFORE merging"
|
||||
)
|
||||
# The idempotent no-op contract (already merged -> no second merge) is documented.
|
||||
assert "no second merge" in lowered, (
|
||||
"deployer prompt must document the already-merged no-op (AC-11)"
|
||||
)
|
||||
|
||||
@@ -148,3 +148,63 @@ def test_tc24_red_catch_up_fails_and_releases_main_stays_green(race_repo, monkey
|
||||
assert _origin_main_sha(origin) == main_before
|
||||
# The lease was released on failure (a later task can proceed).
|
||||
assert merge_gate._read_lease(merge_gate._lease_path(repo)) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ORCH-065 / TC-17: recovery — "rebase+re-test green, merge not done, process
|
||||
# died" -> reaper requeues -> the merge re-drives the STANDARD path WITHOUT a
|
||||
# second expensive re-test when safe (the branch is already up-to-date). AC-10.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc17_redrive_skips_expensive_retest_when_already_caught_up(
|
||||
race_repo, monkeypatch
|
||||
):
|
||||
repo, origin = race_repo
|
||||
main_before = _origin_main_sha(origin)
|
||||
|
||||
# First pass: B catches up (real rebase onto C1) with a GREEN re-test. This is
|
||||
# the work that completed before the process died — the lease is held, the
|
||||
# branch is now caught up on origin.
|
||||
retest_calls = []
|
||||
|
||||
def _retest(r, b):
|
||||
retest_calls.append((r, b))
|
||||
return True, "re-test green"
|
||||
|
||||
monkeypatch.setattr(merge_gate, "retest_branch", _retest)
|
||||
passed, reason = check_branch_mergeable(repo, "ORCH-B", "feature/B")
|
||||
assert passed is True
|
||||
assert reason == "rebased onto main, re-test green"
|
||||
assert len(retest_calls) == 1 # the expensive re-test ran ONCE
|
||||
|
||||
# The process "died" before the merge: release the lease the way the reaper /
|
||||
# reconciler recovery path would (the row is requeued; the branch stays caught
|
||||
# up because the rebase was already pushed).
|
||||
merge_gate.release_merge_lease(repo, "feature/B")
|
||||
|
||||
# Re-drive (standard path) after recovery: the branch already contains
|
||||
# origin/main, so branch_is_behind_main is False and the gate short-circuits to
|
||||
# the up-to-date pass WITHOUT re-running the expensive rebase+re-test.
|
||||
assert merge_gate.branch_is_behind_main(repo, "feature/B") is False
|
||||
passed2, reason2 = check_branch_mergeable(repo, "ORCH-B", "feature/B")
|
||||
assert passed2 is True
|
||||
assert reason2 == "branch up-to-date with main"
|
||||
assert len(retest_calls) == 1 # NOT re-run on the re-drive (no double cost)
|
||||
# origin/main was never pushed by the gate across the whole recovery.
|
||||
assert _origin_main_sha(origin) == main_before
|
||||
|
||||
|
||||
def test_tc17_pr_already_merged_makes_redrive_a_noop(race_repo, monkeypatch):
|
||||
"""If the PR actually merged before the process died, the idempotency guard
|
||||
reports it so the re-drive is a no-op (no second merge)."""
|
||||
import httpx
|
||||
repo, _ = race_repo
|
||||
|
||||
class _R:
|
||||
status_code = 200
|
||||
|
||||
@staticmethod
|
||||
def json():
|
||||
return [{"merged": True}]
|
||||
|
||||
monkeypatch.setattr(httpx, "get", lambda *a, **k: _R())
|
||||
assert merge_gate.pr_already_merged(repo, "feature/B") is True
|
||||
|
||||
138
tests/test_merge_lease_reclaim.py
Normal file
138
tests/test_merge_lease_reclaim.py
Normal file
@@ -0,0 +1,138 @@
|
||||
"""ORCH-065: proactive stale/dead merge-lease reclaim (TC-10..TC-15).
|
||||
|
||||
Exercises merge_gate.reclaim_stale_lease / pid_alive directly with lease files
|
||||
written into a tmp repos_dir. No git ops run (reclaim only removes the lease
|
||||
file). pid liveness is monkeypatched so 'dead'/'alive' are deterministic.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ["ORCH_DB_PATH"] = os.path.join(tempfile.gettempdir(), "test_orch_lease.db")
|
||||
os.environ["ORCH_REPOS_DIR"] = tempfile.gettempdir()
|
||||
os.environ["ORCH_GITEA_TOKEN"] = "test-token"
|
||||
os.environ["ORCH_PLANE_API_TOKEN"] = "test-token"
|
||||
|
||||
from src import merge_gate
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def repos_dir(tmp_path, monkeypatch):
|
||||
d = tmp_path / "repos"
|
||||
d.mkdir()
|
||||
monkeypatch.setattr(merge_gate.settings, "repos_dir", str(d))
|
||||
monkeypatch.setattr(merge_gate.settings, "lease_reclaim_enabled", True)
|
||||
monkeypatch.setattr(merge_gate.settings, "merge_gate_repos", "") # self-hosting only
|
||||
monkeypatch.setattr(merge_gate.settings, "merge_lock_timeout_s", 300)
|
||||
return d
|
||||
|
||||
|
||||
def _write_lease(repos_dir, repo, branch="feature/x", pid=1234, age_s=0):
|
||||
path = os.path.join(str(repos_dir), f".merge-lease-{repo}.json")
|
||||
holder = {
|
||||
"branch": branch,
|
||||
"work_item_id": "ORCH-1",
|
||||
"task_id": 1,
|
||||
"acquired_at": time.time() - age_s,
|
||||
"pid": pid,
|
||||
}
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write(json.dumps(holder))
|
||||
return path
|
||||
|
||||
|
||||
def _no_telegram(monkeypatch):
|
||||
import src.notifications as notif
|
||||
monkeypatch.setattr(notif, "send_telegram", lambda *a, **k: None)
|
||||
|
||||
|
||||
# --- TC-10: reclaim a lease with a DEAD pid, proactively --------------------
|
||||
def test_tc10_reclaim_dead_pid(repos_dir, monkeypatch):
|
||||
_no_telegram(monkeypatch)
|
||||
path = _write_lease(repos_dir, "orchestrator", pid=999999, age_s=0)
|
||||
monkeypatch.setattr(merge_gate, "pid_alive", lambda pid: False)
|
||||
assert merge_gate.reclaim_stale_lease("orchestrator") is True
|
||||
assert not os.path.exists(path) # lease removed
|
||||
|
||||
|
||||
# --- TC-11: reclaim by TTL is preserved -------------------------------------
|
||||
def test_tc11_reclaim_by_ttl(repos_dir, monkeypatch):
|
||||
_no_telegram(monkeypatch)
|
||||
# pid alive, but the lease is older than the TTL -> still reclaimed.
|
||||
path = _write_lease(repos_dir, "orchestrator", pid=4321, age_s=999)
|
||||
monkeypatch.setattr(merge_gate, "pid_alive", lambda pid: True)
|
||||
assert merge_gate.reclaim_stale_lease("orchestrator") is True
|
||||
assert not os.path.exists(path)
|
||||
|
||||
|
||||
# --- TC-12: a LIVE lease within TTL is NOT released -------------------------
|
||||
def test_tc12_live_lease_protected(repos_dir, monkeypatch):
|
||||
_no_telegram(monkeypatch)
|
||||
path = _write_lease(repos_dir, "orchestrator", pid=4321, age_s=10)
|
||||
monkeypatch.setattr(merge_gate, "pid_alive", lambda pid: True)
|
||||
assert merge_gate.reclaim_stale_lease("orchestrator") is False
|
||||
assert os.path.exists(path) # untouched
|
||||
|
||||
|
||||
# --- TC-13: conditional — non-self-hosting repos are a no-op ----------------
|
||||
def test_tc13_non_scope_repo_noop(repos_dir, monkeypatch):
|
||||
_no_telegram(monkeypatch)
|
||||
path = _write_lease(repos_dir, "enduro-trails", pid=999999, age_s=999)
|
||||
monkeypatch.setattr(merge_gate, "pid_alive", lambda pid: False)
|
||||
assert merge_gate.reclaim_stale_lease("enduro-trails") is False
|
||||
assert os.path.exists(path) # out of scope -> untouched
|
||||
|
||||
|
||||
def test_tc13_merge_gate_repos_csv_scope(repos_dir, monkeypatch):
|
||||
_no_telegram(monkeypatch)
|
||||
monkeypatch.setattr(merge_gate.settings, "merge_gate_repos", "enduro-trails")
|
||||
path = _write_lease(repos_dir, "enduro-trails", pid=999999, age_s=0)
|
||||
monkeypatch.setattr(merge_gate, "pid_alive", lambda pid: False)
|
||||
assert merge_gate.reclaim_stale_lease("enduro-trails") is True
|
||||
assert not os.path.exists(path)
|
||||
|
||||
|
||||
# --- TC-14: never-raise on a read/remove error ------------------------------
|
||||
def test_tc14_never_raise_on_read_error(repos_dir, monkeypatch):
|
||||
_no_telegram(monkeypatch)
|
||||
_write_lease(repos_dir, "orchestrator", pid=1, age_s=999)
|
||||
|
||||
def boom(path):
|
||||
raise OSError("simulated read failure")
|
||||
|
||||
monkeypatch.setattr(merge_gate, "_read_lease", boom)
|
||||
# Must not raise; returns False (could not reclaim).
|
||||
assert merge_gate.reclaim_stale_lease("orchestrator") is False
|
||||
|
||||
|
||||
def test_tc14_no_lease_file_is_noop(repos_dir, monkeypatch):
|
||||
_no_telegram(monkeypatch)
|
||||
assert merge_gate.reclaim_stale_lease("orchestrator") is False
|
||||
|
||||
|
||||
# --- TC-15: kill-switch lease_reclaim_enabled=False -------------------------
|
||||
def test_tc15_kill_switch(repos_dir, monkeypatch):
|
||||
_no_telegram(monkeypatch)
|
||||
monkeypatch.setattr(merge_gate.settings, "lease_reclaim_enabled", False)
|
||||
path = _write_lease(repos_dir, "orchestrator", pid=999999, age_s=999)
|
||||
monkeypatch.setattr(merge_gate, "pid_alive", lambda pid: False)
|
||||
assert merge_gate.reclaim_stale_lease("orchestrator") is False
|
||||
assert os.path.exists(path) # proactive reclaim off -> untouched
|
||||
|
||||
|
||||
# --- pid_alive semantics ----------------------------------------------------
|
||||
def test_pid_alive_dead_process():
|
||||
# PID 999999999 almost certainly does not exist.
|
||||
assert merge_gate.pid_alive(999999999) is False
|
||||
|
||||
|
||||
def test_pid_alive_self():
|
||||
assert merge_gate.pid_alive(os.getpid()) is True
|
||||
|
||||
|
||||
def test_pid_alive_missing_pid_conservative():
|
||||
assert merge_gate.pid_alive(None) is True
|
||||
assert merge_gate.pid_alive(0) is True
|
||||
@@ -30,6 +30,7 @@ _EXPECTED_QGS = {
|
||||
"check_staging_status",
|
||||
"check_branch_mergeable", # ORCH-043 merge-gate (deploy-staging -> deploy edge)
|
||||
"check_staging_image_fresh", # ORCH-058 image-freshness sub-gate (same edge)
|
||||
"check_security_gate", # ORCH-022 security sub-gate (same edge, run FIRST)
|
||||
}
|
||||
|
||||
|
||||
|
||||
113
tests/test_qg_security.py
Normal file
113
tests/test_qg_security.py
Normal file
@@ -0,0 +1,113 @@
|
||||
"""ORCH-022 / TC-13..TC-15: the security-gate QG wrapper + registry wiring.
|
||||
|
||||
Covers the thin ``check_security_gate`` registry wrapper in src/qg/checks.py (its
|
||||
conditionality fast-paths) and that the new check is registered + dispatched by
|
||||
``_run_qg``. The deterministic core (scan / verdict / frontmatter) is covered in
|
||||
tests/test_security_gate.py.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
|
||||
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
|
||||
|
||||
from src import security_gate as sg # noqa: E402
|
||||
from src.qg import checks as qg # noqa: E402
|
||||
from src.qg.checks import QG_CHECKS, check_security_gate # noqa: E402
|
||||
|
||||
_WI = "ORCH-022"
|
||||
_BRANCH = "feature/ORCH-022-x"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-13 — non-self repo with empty scope -> N/A fast pass (no scanner run).
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc13_non_self_repo_empty_scope_is_na(monkeypatch):
|
||||
"""TC-13: a non-self repo with an empty scope -> (True, 'security-gate N/A
|
||||
for <repo>') immediately, WITHOUT invoking the scanners."""
|
||||
monkeypatch.setattr(sg.settings, "security_gate_enabled", True)
|
||||
monkeypatch.setattr(sg.settings, "security_gate_repos", "")
|
||||
|
||||
called = {"scan": False}
|
||||
|
||||
def _should_not_run(*a, **k):
|
||||
called["scan"] = True
|
||||
raise AssertionError("scanner must not run for an N/A repo")
|
||||
|
||||
monkeypatch.setattr(sg, "scan_secrets", _should_not_run)
|
||||
monkeypatch.setattr(sg, "audit_dependencies", _should_not_run)
|
||||
|
||||
ok, reason = check_security_gate("enduro-trails", _WI, _BRANCH)
|
||||
assert ok is True
|
||||
assert "N/A" in reason
|
||||
assert "enduro-trails" in reason
|
||||
assert called["scan"] is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-14 — kill-switch disabled -> no-op pass.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc14_disabled_is_noop_pass(monkeypatch):
|
||||
"""TC-14: ORCH_SECURITY_GATE_ENABLED=false -> no-op pass (True), scanners untouched."""
|
||||
monkeypatch.setattr(sg.settings, "security_gate_enabled", False)
|
||||
|
||||
def _should_not_run(*a, **k):
|
||||
raise AssertionError("scanner must not run when the gate is disabled")
|
||||
|
||||
monkeypatch.setattr(sg, "scan_secrets", _should_not_run)
|
||||
monkeypatch.setattr(sg, "audit_dependencies", _should_not_run)
|
||||
|
||||
ok, reason = check_security_gate("orchestrator", _WI, _BRANCH)
|
||||
assert ok is True
|
||||
assert "disabled" in reason.lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-15 — registered in QG_CHECKS + dispatched by _run_qg.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc15_registered_in_qg_checks():
|
||||
"""TC-15a: the new check is registered and callable."""
|
||||
assert "check_security_gate" in QG_CHECKS
|
||||
assert QG_CHECKS["check_security_gate"] is check_security_gate
|
||||
assert callable(QG_CHECKS["check_security_gate"])
|
||||
|
||||
|
||||
def test_tc15_dispatched_by_run_qg(monkeypatch):
|
||||
"""TC-15b: _run_qg routes 'check_security_gate' with the (repo, work_item_id,
|
||||
branch) signature to the registered wrapper."""
|
||||
from src import stage_engine
|
||||
|
||||
captured = {}
|
||||
|
||||
def _fake(repo, work_item_id, branch):
|
||||
captured["args"] = (repo, work_item_id, branch)
|
||||
return True, "ok"
|
||||
|
||||
monkeypatch.setitem(stage_engine.QG_CHECKS, "check_security_gate", _fake)
|
||||
passed, reason = stage_engine._run_qg("check_security_gate", "orchestrator", _WI, _BRANCH)
|
||||
assert passed is True
|
||||
assert captured["args"] == ("orchestrator", _WI, _BRANCH)
|
||||
|
||||
|
||||
def test_security_gate_applies_scope(monkeypatch):
|
||||
"""Conditionality matrix mirrors merge_gate_applies / image_freshness_applies."""
|
||||
monkeypatch.setattr(sg.settings, "security_gate_enabled", True)
|
||||
# Empty scope -> only the self-hosting repo.
|
||||
monkeypatch.setattr(sg.settings, "security_gate_repos", "")
|
||||
assert sg.security_gate_applies("orchestrator") is True
|
||||
assert sg.security_gate_applies("enduro-trails") is False
|
||||
# Explicit CSV scope -> only the listed repos (case-insensitive).
|
||||
monkeypatch.setattr(sg.settings, "security_gate_repos", "enduro-trails, foo")
|
||||
assert sg.security_gate_applies("enduro-trails") is True
|
||||
assert sg.security_gate_applies("orchestrator") is False
|
||||
# Kill-switch wins over everything.
|
||||
monkeypatch.setattr(sg.settings, "security_gate_enabled", False)
|
||||
assert sg.security_gate_applies("orchestrator") is False
|
||||
|
||||
|
||||
def test_qg_wrapper_delegates(monkeypatch):
|
||||
"""The QG wrapper delegates to security_gate.check_security_gate verbatim."""
|
||||
monkeypatch.setattr(sg, "check_security_gate", lambda r, w, b: (False, "delegated FAIL"))
|
||||
ok, reason = check_security_gate("orchestrator", _WI, _BRANCH)
|
||||
assert ok is False
|
||||
assert reason == "delegated FAIL"
|
||||
@@ -302,3 +302,58 @@ class TestWorkerConcurrency:
|
||||
assert count_running_jobs() == 0
|
||||
counts = job_status_counts()
|
||||
assert counts["failed"] == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ORCH-065: job-reaper unblocks the shared queue (TC-09) + /queue block (TC-18)
|
||||
# ---------------------------------------------------------------------------
|
||||
class TestReaperUnblocksQueue:
|
||||
def test_tc09_reap_unblocks_claim_at_concurrency_1(self, monkeypatch):
|
||||
"""A zombie 'running' row at max_concurrency=1 blocks every claim; once the
|
||||
reaper reaps it the next queued job can be claimed (AC-2)."""
|
||||
import src.merge_gate as mg
|
||||
from src.job_reaper import JobReaper
|
||||
|
||||
monkeypatch.setattr(db.settings, "reaper_dead_ticks", 1)
|
||||
monkeypatch.setattr(mg, "pid_alive", lambda pid: False) # zombie pid dead
|
||||
|
||||
# A zombie row stuck 'running' with a dead pid.
|
||||
conn = db.get_db()
|
||||
cur = conn.execute(
|
||||
"INSERT INTO jobs (agent, repo, status, attempts, max_attempts, pid, "
|
||||
"started_at) VALUES ('developer','r','running',2,2,999999,datetime('now'))"
|
||||
)
|
||||
zombie = cur.lastrowid
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
# A second job waits in the queue behind it.
|
||||
nxt = enqueue_job("analyst", "r")
|
||||
|
||||
# At concurrency 1 the slot is fully occupied -> nothing else can run.
|
||||
assert count_running_jobs() == 1
|
||||
|
||||
monkeypatch.setattr("src.notifications.send_telegram", lambda *a, **k: None)
|
||||
JobReaper().reap_once() # dead pid, attempts>=max -> failed
|
||||
|
||||
assert get_job(zombie)["status"] == "failed"
|
||||
assert count_running_jobs() == 0
|
||||
# Queue is unblocked: the next job claims successfully.
|
||||
claimed = claim_next_job()
|
||||
assert claimed is not None and claimed["id"] == nxt
|
||||
|
||||
def test_tc18_queue_endpoint_has_reaper_block(self):
|
||||
"""GET /queue exposes the reaper observability block (AC-15).
|
||||
|
||||
Calls the endpoint coroutine directly (no lifespan / no background
|
||||
threads / no network) so the test stays hermetic.
|
||||
"""
|
||||
import asyncio
|
||||
import src.main as main
|
||||
|
||||
body = asyncio.run(main.queue())
|
||||
assert "reaper" in body
|
||||
reaper = body["reaper"]
|
||||
for key in ("enabled", "interval", "last_run_ts", "reaped_total",
|
||||
"last_reaped", "lease_reclaimed_total"):
|
||||
assert key in reaper
|
||||
|
||||
324
tests/test_security_gate.py
Normal file
324
tests/test_security_gate.py
Normal file
@@ -0,0 +1,324 @@
|
||||
"""ORCH-022 / TC-01..TC-12: the security-gate leaf module (src/security_gate.py).
|
||||
|
||||
These exercise the DETERMINISTIC core: the pure classifier / verdict / frontmatter
|
||||
helpers (no binaries needed) plus scan_secrets / audit_dependencies with the
|
||||
external scanners (gitleaks / pip-audit) mocked at subprocess.run. The integration
|
||||
of the gate into advance_stage is covered in tests/test_stage_engine_security_gate.py;
|
||||
the QG registry wiring in tests/test_qg_security.py.
|
||||
|
||||
Contract under test (ADR-001 §7):
|
||||
* secrets are UNCONDITIONAL + offline -> a found secret blocks; a tool error is
|
||||
fail-closed (FAIL);
|
||||
* dependency audit is best-effort -> blocking only at/over the severity threshold;
|
||||
UNKNOWN / below-threshold -> warning; an unreachable feed degrades fail-open +
|
||||
warning by default, fail-closed only when configured;
|
||||
* the machine verdict lives ONLY in the YAML frontmatter (read-back == written);
|
||||
* never-raise: any internal error -> (False, reason), no exception escapes.
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
|
||||
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
|
||||
|
||||
import pytest # noqa: E402
|
||||
|
||||
from src import security_gate as sg # noqa: E402
|
||||
|
||||
_REPO = "orchestrator"
|
||||
_BRANCH = "feature/ORCH-022-x"
|
||||
_WI = "ORCH-022"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Builders for the result containers (no binaries needed).
|
||||
# ---------------------------------------------------------------------------
|
||||
def _clean_secret():
|
||||
return sg.SecretScanResult(status="clean", detail="no secrets found")
|
||||
|
||||
|
||||
def _found_secret(n=1):
|
||||
findings = [
|
||||
{"file": "src/config.py", "rule": "generic-api-key", "line": 12 + i, "match": "abcd…yz"}
|
||||
for i in range(n)
|
||||
]
|
||||
return sg.SecretScanResult(status="found", findings=findings, detail=f"{n} secret(s)")
|
||||
|
||||
|
||||
def _ok_deps(findings=None):
|
||||
return sg.DepAuditResult(status="ok", findings=findings or [], detail="ok")
|
||||
|
||||
|
||||
def _degraded_deps():
|
||||
return sg.DepAuditResult(status="degraded", detail="pip-audit feed unavailable")
|
||||
|
||||
|
||||
def _verdict(secret, dep, *, secrets_block=True, dep_block_severity="HIGH", dep_fail_closed=False):
|
||||
return sg.compute_verdict(
|
||||
secret, dep,
|
||||
secrets_block=secrets_block,
|
||||
dep_block_severity=dep_block_severity,
|
||||
dep_fail_closed=dep_fail_closed,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-01 / TC-02 / TC-03 — secret-scanning (FR-1 / AC-1..AC-3)
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc01_secret_in_diff_fails():
|
||||
"""TC-01: a planted secret -> FAIL, secrets_found>=1, reason names the finding."""
|
||||
fields = _verdict(_found_secret(1), _ok_deps())
|
||||
assert fields["security_status"] == "FAIL"
|
||||
assert fields["secrets_found"] >= 1
|
||||
# The reason must name the finding substance (rule + file), not just "FAIL".
|
||||
assert "generic-api-key" in fields["reason"]
|
||||
assert "src/config.py" in fields["reason"]
|
||||
|
||||
|
||||
def test_tc02_clean_branch_passes():
|
||||
"""TC-02: a clean branch -> PASS, secrets_found=0."""
|
||||
fields = _verdict(_clean_secret(), _ok_deps())
|
||||
assert fields["security_status"] == "PASS"
|
||||
assert fields["secrets_found"] == 0
|
||||
assert fields["deps_blocking"] == 0
|
||||
|
||||
|
||||
def test_tc03_allowlisted_match_does_not_fail(monkeypatch, tmp_path):
|
||||
"""TC-03: an allowlisted match (placeholder / fixture) is filtered by gitleaks
|
||||
(rc=0) -> scan_secrets reports clean -> PASS. The allowlist lives in the
|
||||
versioned .gitleaks.toml; here we assert the gate honours gitleaks' rc=0."""
|
||||
wt = tmp_path / "wt"
|
||||
wt.mkdir()
|
||||
monkeypatch.setattr(sg, "ensure_worktree", lambda r, b: str(wt))
|
||||
|
||||
def _fake_run(cmd, **kwargs):
|
||||
# `git fetch` and `gitleaks detect` both routed here; both "succeed clean".
|
||||
return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
|
||||
|
||||
monkeypatch.setattr(sg.subprocess, "run", _fake_run)
|
||||
res = sg.scan_secrets(_REPO, _BRANCH)
|
||||
assert res.status == "clean"
|
||||
fields = _verdict(res, _ok_deps())
|
||||
assert fields["security_status"] == "PASS"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-04..TC-07 — dependency audit + thresholds (FR-2 / AC-4..AC-7)
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc04_high_cve_at_high_threshold_blocks():
|
||||
"""TC-04: a HIGH/CRITICAL CVE at threshold HIGH -> FAIL, deps_blocking>=1."""
|
||||
deps = _ok_deps([
|
||||
{"package": "requests", "version": "2.0.0", "id": "CVE-1", "severity": "HIGH", "fix": "2.1"},
|
||||
{"package": "urllib3", "version": "1.0.0", "id": "CVE-2", "severity": "CRITICAL", "fix": "1.1"},
|
||||
])
|
||||
fields = _verdict(_clean_secret(), deps, dep_block_severity="HIGH")
|
||||
assert fields["security_status"] == "FAIL"
|
||||
assert fields["deps_blocking"] >= 1
|
||||
assert "CVE-1" in fields["reason"] or "CVE-2" in fields["reason"]
|
||||
|
||||
|
||||
def test_tc05_only_medium_low_warns_passes():
|
||||
"""TC-05: only MEDIUM/LOW vulns -> PASS, deps_warning>=1, findings in the body."""
|
||||
deps = _ok_deps([
|
||||
{"package": "jinja2", "version": "2.0", "id": "CVE-M", "severity": "MEDIUM", "fix": "2.1"},
|
||||
{"package": "click", "version": "7.0", "id": "CVE-L", "severity": "LOW", "fix": ""},
|
||||
])
|
||||
fields = _verdict(_clean_secret(), deps, dep_block_severity="HIGH")
|
||||
assert fields["security_status"] == "PASS"
|
||||
assert fields["deps_warning"] >= 1
|
||||
assert fields["deps_blocking"] == 0
|
||||
body = sg.render_security_report(_WI, fields)
|
||||
assert "CVE-M" in body and "CVE-L" in body
|
||||
|
||||
|
||||
def test_tc06_threshold_config_changes_classification():
|
||||
"""TC-06: severity=CRITICAL makes a HIGH CVE a warning; severity=HIGH blocks it."""
|
||||
assert sg.classify_severity("HIGH", "CRITICAL") == "warning"
|
||||
assert sg.classify_severity("HIGH", "HIGH") == "block"
|
||||
assert sg.classify_severity("CRITICAL", "CRITICAL") == "block"
|
||||
# UNKNOWN is ALWAYS a warning, never an auto-block (anti-loop, Р-4).
|
||||
assert sg.classify_severity("UNKNOWN", "LOW") == "warning"
|
||||
assert sg.classify_severity("", "HIGH") == "warning"
|
||||
|
||||
deps = _ok_deps([
|
||||
{"package": "x", "version": "1", "id": "CVE-H", "severity": "HIGH", "fix": ""},
|
||||
])
|
||||
at_critical = _verdict(_clean_secret(), deps, dep_block_severity="CRITICAL")
|
||||
at_high = _verdict(_clean_secret(), deps, dep_block_severity="HIGH")
|
||||
assert at_critical["security_status"] == "PASS"
|
||||
assert at_critical["deps_warning"] == 1
|
||||
assert at_high["security_status"] == "FAIL"
|
||||
assert at_high["deps_blocking"] == 1
|
||||
|
||||
|
||||
def test_tc07_degraded_feed_failopen_default_failclosed_strict():
|
||||
"""TC-07: an unreachable CVE feed degrades fail-open + warning by default (no
|
||||
exception, no false FAIL); fail-closed -> FAIL only when configured."""
|
||||
default = _verdict(_clean_secret(), _degraded_deps(), dep_fail_closed=False)
|
||||
assert default["security_status"] == "PASS"
|
||||
assert default["deps_audit_degraded"] is True
|
||||
|
||||
strict = _verdict(_clean_secret(), _degraded_deps(), dep_fail_closed=True)
|
||||
assert strict["security_status"] == "FAIL"
|
||||
assert strict["deps_audit_degraded"] is True
|
||||
assert "fail-closed" in strict["reason"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-08..TC-10 — verdict / frontmatter parser + artefact (FR-3 / AC-8..AC-10)
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc08_verdict_only_from_frontmatter():
|
||||
"""TC-08: the verdict is read ONLY from the YAML frontmatter; prose in the body
|
||||
does not influence it; the negative (FAIL) token is authoritative."""
|
||||
# Frontmatter PASS but body screams FAIL -> still PASS (prose ignored).
|
||||
pass_fm = (
|
||||
"---\nsecurity_status: PASS\nsecrets_found: 0\n---\n"
|
||||
"# Report\nThis build totally FAILED everything, FAIL FAIL.\n"
|
||||
)
|
||||
ok, reason = sg.parse_security_status(pass_fm)
|
||||
assert ok is True
|
||||
assert "PASS" in reason
|
||||
|
||||
# Frontmatter FAIL but body says PASS -> FAIL (negative token authoritative).
|
||||
fail_fm = "---\nsecurity_status: FAIL\n---\nEverything PASS, looks great!\n"
|
||||
ok, reason = sg.parse_security_status(fail_fm)
|
||||
assert ok is False
|
||||
assert "FAIL" in reason
|
||||
|
||||
|
||||
def test_tc09_missing_or_broken_frontmatter_failclosed():
|
||||
"""TC-09: no frontmatter / broken YAML / missing field -> (False, reason)."""
|
||||
# No frontmatter at all.
|
||||
ok, reason = sg.parse_security_status("# Just a body, no frontmatter\nPASS\n")
|
||||
assert ok is False and reason
|
||||
|
||||
# Frontmatter present but no security_status field.
|
||||
ok, reason = sg.parse_security_status("---\nother: 1\n---\nbody\n")
|
||||
assert ok is False
|
||||
|
||||
# Broken YAML in the frontmatter.
|
||||
ok, reason = sg.parse_security_status("---\nsecurity_status: : : [bad\n---\nbody\n")
|
||||
assert ok is False
|
||||
|
||||
|
||||
def test_tc10_artifact_has_valid_frontmatter_and_body(tmp_path, monkeypatch):
|
||||
"""TC-10: 17-security-report.md is written with valid frontmatter (all machine
|
||||
fields) and a body listing the findings; read-back == the written verdict."""
|
||||
wt = tmp_path / "wt"
|
||||
wt.mkdir()
|
||||
monkeypatch.setattr(sg, "get_worktree_path", lambda r, b: str(wt))
|
||||
monkeypatch.setattr(sg, "ensure_worktree", lambda r, b: str(wt))
|
||||
|
||||
deps = _ok_deps([
|
||||
{"package": "requests", "version": "2.0", "id": "CVE-X", "severity": "HIGH", "fix": "2.1"},
|
||||
{"package": "click", "version": "7.0", "id": "CVE-L", "severity": "LOW", "fix": ""},
|
||||
])
|
||||
fields = _verdict(_found_secret(1), deps, dep_block_severity="HIGH")
|
||||
path = sg.write_security_report(_REPO, _WI, _BRANCH, fields)
|
||||
assert os.path.isfile(path)
|
||||
with open(path, encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
# Frontmatter carries every machine field.
|
||||
for key in ("security_status", "secrets_found", "deps_blocking", "deps_warning",
|
||||
"deps_audit_degraded"):
|
||||
assert f"{key}:" in content
|
||||
# Body lists findings.
|
||||
assert "CVE-X" in content and "CVE-L" in content
|
||||
# Read-back agrees with the computed status (single source of truth, AC-8).
|
||||
ok, _ = sg.parse_security_status(content)
|
||||
assert ok is (fields["security_status"] == "PASS")
|
||||
assert ok is False # this fixture is a FAIL (secret + HIGH CVE)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-11 / TC-12 — never-raise / timeout (FR-5/FR-6 / AC-14..AC-17)
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc11_missing_binary_failclosed_never_raises(monkeypatch, tmp_path):
|
||||
"""TC-11: a missing scanner binary / internal exception -> error -> FAIL
|
||||
(fail-closed for secrets), and the exception never propagates."""
|
||||
wt = tmp_path / "wt"
|
||||
wt.mkdir()
|
||||
monkeypatch.setattr(sg, "ensure_worktree", lambda r, b: str(wt))
|
||||
|
||||
def _raise_fnf(cmd, **kwargs):
|
||||
# git fetch ok, gitleaks missing.
|
||||
if cmd[:1] == ["git"]:
|
||||
return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
|
||||
raise FileNotFoundError("gitleaks")
|
||||
|
||||
monkeypatch.setattr(sg.subprocess, "run", _raise_fnf)
|
||||
res = sg.scan_secrets(_REPO, _BRANCH)
|
||||
assert res.status == "error"
|
||||
fields = _verdict(res, _ok_deps())
|
||||
assert fields["security_status"] == "FAIL" # fail-closed, BR-2
|
||||
assert "fail-closed" in fields["reason"]
|
||||
|
||||
# check_security_gate as a whole never raises even if everything explodes.
|
||||
monkeypatch.setattr(sg, "security_gate_applies", lambda r: True)
|
||||
|
||||
def _boom(*a, **k):
|
||||
raise RuntimeError("kaboom")
|
||||
|
||||
monkeypatch.setattr(sg, "scan_secrets", _boom)
|
||||
ok, reason = sg.check_security_gate(_REPO, _WI, _BRANCH)
|
||||
assert ok is False
|
||||
assert "error" in reason.lower()
|
||||
|
||||
|
||||
def test_tc12_timeout_is_deterministic_failclosed(monkeypatch, tmp_path):
|
||||
"""TC-12: exceeding the scan timeout -> a deterministic error verdict, no hang."""
|
||||
wt = tmp_path / "wt"
|
||||
wt.mkdir()
|
||||
monkeypatch.setattr(sg, "ensure_worktree", lambda r, b: str(wt))
|
||||
|
||||
def _timeout(cmd, **kwargs):
|
||||
if cmd[:1] == ["git"]:
|
||||
return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
|
||||
raise subprocess.TimeoutExpired(cmd, kwargs.get("timeout", 1))
|
||||
|
||||
monkeypatch.setattr(sg.subprocess, "run", _timeout)
|
||||
res = sg.scan_secrets(_REPO, _BRANCH)
|
||||
assert res.status == "error"
|
||||
assert "timeout" in res.detail.lower()
|
||||
fields = _verdict(res, _ok_deps())
|
||||
assert fields["security_status"] == "FAIL"
|
||||
|
||||
# pip-audit timeout -> degrade (best-effort), not a hard error.
|
||||
monkeypatch.setattr(sg, "get_worktree_path", lambda r, b: str(wt))
|
||||
(wt / "requirements.txt").write_text("requests==2.0\n")
|
||||
dep = sg.audit_dependencies(_REPO, _BRANCH)
|
||||
assert dep.status == "degraded"
|
||||
assert "timeout" in dep.detail.lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Parser robustness (supports the above; pure, never raises)
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_parse_gitleaks_report_tolerant():
|
||||
assert sg.parse_gitleaks_report("") == []
|
||||
assert sg.parse_gitleaks_report("not json") == []
|
||||
assert sg.parse_gitleaks_report("{}") == []
|
||||
parsed = sg.parse_gitleaks_report(
|
||||
'[{"File":"a.py","RuleID":"key","StartLine":3,"Secret":"supersecretvalue"}]'
|
||||
)
|
||||
assert parsed[0]["file"] == "a.py"
|
||||
assert parsed[0]["rule"] == "key"
|
||||
# The secret value is masked, never re-leaked verbatim.
|
||||
assert "supersecretvalue" not in parsed[0]["match"]
|
||||
|
||||
|
||||
def test_parse_pip_audit_report_tolerant():
|
||||
assert sg.parse_pip_audit_report("") == []
|
||||
assert sg.parse_pip_audit_report("garbage") == []
|
||||
doc = (
|
||||
'{"dependencies":[{"name":"requests","version":"2.0",'
|
||||
'"vulns":[{"id":"CVE-1","severity":"HIGH","fix_versions":["2.1"]}]}]}'
|
||||
)
|
||||
parsed = sg.parse_pip_audit_report(doc)
|
||||
assert parsed[0]["package"] == "requests"
|
||||
assert parsed[0]["severity"] == "HIGH"
|
||||
# Missing severity -> UNKNOWN.
|
||||
doc2 = '{"dependencies":[{"name":"x","version":"1","vulns":[{"id":"CVE-2"}]}]}'
|
||||
assert sg.parse_pip_audit_report(doc2)[0]["severity"] == "UNKNOWN"
|
||||
@@ -832,6 +832,7 @@ class TestMergeGate:
|
||||
stage_engine, "QG_CHECKS",
|
||||
{**stage_engine.QG_CHECKS,
|
||||
"check_staging_status": _pass,
|
||||
"check_security_gate": _pass,
|
||||
"check_branch_mergeable": _pass,
|
||||
"check_staging_image_fresh": _pass},
|
||||
)
|
||||
@@ -856,6 +857,7 @@ class TestMergeGate:
|
||||
stage_engine, "QG_CHECKS",
|
||||
{**stage_engine.QG_CHECKS,
|
||||
"check_staging_status": _pass,
|
||||
"check_security_gate": _pass,
|
||||
"check_branch_mergeable": _fail("merge-lock busy")},
|
||||
)
|
||||
monkeypatch.setattr(stage_engine.settings, "merge_defer_delay_s", 30)
|
||||
@@ -883,6 +885,7 @@ class TestMergeGate:
|
||||
stage_engine, "QG_CHECKS",
|
||||
{**stage_engine.QG_CHECKS,
|
||||
"check_staging_status": _pass,
|
||||
"check_security_gate": _pass,
|
||||
"check_branch_mergeable": _fail("merge-lock busy")},
|
||||
)
|
||||
monkeypatch.setattr(stage_engine.settings, "merge_defer_max_attempts", 3)
|
||||
@@ -916,6 +919,7 @@ class TestMergeGate:
|
||||
stage_engine, "QG_CHECKS",
|
||||
{**stage_engine.QG_CHECKS,
|
||||
"check_staging_status": _pass,
|
||||
"check_security_gate": _pass,
|
||||
"check_branch_mergeable": _fail("rebase conflict: src/db.py")},
|
||||
)
|
||||
task_id = _make_task("deploy-staging", repo="orchestrator", wi="ORCH-043",
|
||||
@@ -939,6 +943,7 @@ class TestMergeGate:
|
||||
stage_engine, "QG_CHECKS",
|
||||
{**stage_engine.QG_CHECKS,
|
||||
"check_staging_status": _pass,
|
||||
"check_security_gate": _pass,
|
||||
"check_branch_mergeable": _fail("re-test failed after rebase: 1 failed")},
|
||||
)
|
||||
task_id = _make_task("deploy-staging", repo="orchestrator", wi="ORCH-043",
|
||||
@@ -962,6 +967,7 @@ class TestMergeGate:
|
||||
stage_engine, "QG_CHECKS",
|
||||
{**stage_engine.QG_CHECKS,
|
||||
"check_staging_status": _pass,
|
||||
"check_security_gate": _pass,
|
||||
"check_branch_mergeable": _fail("rebase conflict: src/db.py")},
|
||||
)
|
||||
task_id = _make_task("deploy-staging", repo="orchestrator", wi="ORCH-043",
|
||||
@@ -1014,6 +1020,7 @@ class TestImageFreshnessGate:
|
||||
stage_engine, "QG_CHECKS",
|
||||
{**stage_engine.QG_CHECKS,
|
||||
"check_staging_status": _pass,
|
||||
"check_security_gate": _pass,
|
||||
"check_branch_mergeable": _pass,
|
||||
"check_staging_image_fresh": _fail(
|
||||
"staging rebuild failed: health FAILED")},
|
||||
@@ -1041,6 +1048,7 @@ class TestImageFreshnessGate:
|
||||
stage_engine, "QG_CHECKS",
|
||||
{**stage_engine.QG_CHECKS,
|
||||
"check_staging_status": _pass,
|
||||
"check_security_gate": _pass,
|
||||
"check_branch_mergeable": _pass,
|
||||
"check_staging_image_fresh": _fail("provenance mismatch")},
|
||||
)
|
||||
@@ -1064,6 +1072,7 @@ class TestImageFreshnessGate:
|
||||
stage_engine, "QG_CHECKS",
|
||||
{**stage_engine.QG_CHECKS,
|
||||
"check_staging_status": _pass,
|
||||
"check_security_gate": _pass,
|
||||
"check_branch_mergeable": _pass,
|
||||
"check_staging_image_fresh": _pass},
|
||||
)
|
||||
@@ -1089,6 +1098,7 @@ class TestImageFreshnessGate:
|
||||
stage_engine, "QG_CHECKS",
|
||||
{**stage_engine.QG_CHECKS,
|
||||
"check_staging_status": _pass,
|
||||
"check_security_gate": _pass,
|
||||
"check_branch_mergeable": _pass},
|
||||
) # check_staging_image_fresh left REAL -> N/A for enduro-trails
|
||||
task_id = _make_task("deploy-staging", repo="enduro-trails", wi="ET-099",
|
||||
@@ -1160,6 +1170,7 @@ class TestStagingInfraTolerance:
|
||||
stage_engine, "QG_CHECKS",
|
||||
{**stage_engine.QG_CHECKS,
|
||||
"check_staging_status": _pass,
|
||||
"check_security_gate": _pass,
|
||||
"check_branch_mergeable": _pass,
|
||||
"check_staging_image_fresh": _pass},
|
||||
)
|
||||
@@ -1232,6 +1243,7 @@ class TestStagingInfraTolerance:
|
||||
stage_engine, "QG_CHECKS",
|
||||
{**stage_engine.QG_CHECKS,
|
||||
"check_staging_status": _pass,
|
||||
"check_security_gate": _pass,
|
||||
"check_branch_mergeable": _pass,
|
||||
"check_staging_image_fresh": _pass,
|
||||
"check_deploy_status": _pass},
|
||||
|
||||
270
tests/test_stage_engine_security_gate.py
Normal file
270
tests/test_stage_engine_security_gate.py
Normal file
@@ -0,0 +1,270 @@
|
||||
"""ORCH-022 / TC-16..TC-19, TC-21: the security sub-gate wired into advance_stage.
|
||||
|
||||
These are integration tests over src.stage_engine.advance_stage on the
|
||||
deploy-staging -> deploy edge. The security verdict is injected by patching the
|
||||
QG_CHECKS registry entry (the leaf scanner logic is unit-tested in
|
||||
tests/test_security_gate.py), so we exercise the ENGINE behaviour:
|
||||
* FAIL -> rollback to development + enqueue developer + Plane comment + notify;
|
||||
* the rollback task_desc carries the verbatim findings (ORCH-046 pattern);
|
||||
* after MAX_DEVELOPER_RETRIES -> set_issue_blocked + Telegram, no bounce;
|
||||
* PASS -> the pipeline advances normally (no rollback, no noisy notify);
|
||||
* self-hosting safety: a FAIL never calls the deploy hook / restarts prod.
|
||||
|
||||
Network/Plane/Telegram side effects are mocked at the src.stage_engine level.
|
||||
"""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
_test_db = os.path.join(tempfile.gettempdir(), "test_orchestrator_security_gate.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.stage_engine import advance_stage # noqa: E402
|
||||
|
||||
_BRANCH = "feature/ORCH-022-x"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures (mirror tests/test_stage_engine.py)
|
||||
# ---------------------------------------------------------------------------
|
||||
@pytest.fixture(autouse=True)
|
||||
def fresh_db(monkeypatch):
|
||||
monkeypatch.setattr(_db.settings, "db_path", _test_db)
|
||||
if os.path.exists(_test_db):
|
||||
os.unlink(_test_db)
|
||||
init_db()
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def silence_side_effects(monkeypatch):
|
||||
for name in (
|
||||
"notify_stage_change",
|
||||
"notify_qg_failure",
|
||||
"notify_approve_requested",
|
||||
"send_telegram",
|
||||
"plane_notify_stage",
|
||||
"plane_notify_qg",
|
||||
"plane_add_comment",
|
||||
"set_issue_in_review",
|
||||
"set_issue_needs_input",
|
||||
"set_issue_in_progress",
|
||||
"set_issue_blocked",
|
||||
"set_issue_done",
|
||||
):
|
||||
monkeypatch.setattr(stage_engine, name, MagicMock())
|
||||
|
||||
|
||||
def _make_task(stage, repo, branch=_BRANCH, wi="ORCH-022"):
|
||||
conn = get_db()
|
||||
cur = conn.execute(
|
||||
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage) "
|
||||
"VALUES (?, ?, ?, ?, ?)",
|
||||
(f"plane-{wi}", wi, repo, branch, stage),
|
||||
)
|
||||
task_id = cur.lastrowid
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return task_id
|
||||
|
||||
|
||||
def _stage(task_id):
|
||||
conn = get_db()
|
||||
row = conn.execute("SELECT stage FROM tasks WHERE id=?", (task_id,)).fetchone()
|
||||
conn.close()
|
||||
return row[0]
|
||||
|
||||
|
||||
def _jobs():
|
||||
conn = get_db()
|
||||
rows = conn.execute("SELECT agent, repo, task_id FROM jobs ORDER BY id").fetchall()
|
||||
conn.close()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def _job_contents():
|
||||
conn = get_db()
|
||||
rows = conn.execute("SELECT task_content FROM jobs ORDER BY id").fetchall()
|
||||
conn.close()
|
||||
return [r[0] for r in rows]
|
||||
|
||||
|
||||
def _add_developer_runs(task_id, n):
|
||||
conn = get_db()
|
||||
for _ in range(n):
|
||||
conn.execute(
|
||||
"INSERT INTO agent_runs (task_id, agent) VALUES (?, 'developer')",
|
||||
(task_id,),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def _pass(*a, **k):
|
||||
return (True, "ok")
|
||||
|
||||
|
||||
def _fail(reason):
|
||||
def _f(*a, **k):
|
||||
return (False, reason)
|
||||
return _f
|
||||
|
||||
|
||||
def _qg_with_security(monkeypatch, security_result):
|
||||
"""Patch QG_CHECKS so every gate passes EXCEPT the security gate, which returns
|
||||
``security_result``. Keeps the deploy-staging edge reachable (check_staging_status
|
||||
passes) and isolates the security verdict under test."""
|
||||
patched = {k: _pass for k in stage_engine.QG_CHECKS}
|
||||
patched["check_security_gate"] = security_result
|
||||
monkeypatch.setattr(stage_engine, "QG_CHECKS", patched)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-16 — FAIL -> rollback to development + enqueue developer + notify.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc16_fail_rolls_back_and_enqueues_developer(monkeypatch):
|
||||
"""TC-16: security_status FAIL -> rollback deploy-staging -> development,
|
||||
enqueue developer, Plane comment + notify_qg_failure."""
|
||||
_qg_with_security(monkeypatch, _fail("2 secret(s): aws-key in src/x.py:3"))
|
||||
task_id = _make_task("deploy-staging", repo="enduro-trails")
|
||||
|
||||
res = advance_stage(
|
||||
task_id, "deploy-staging", "enduro-trails", "ORCH-022", _BRANCH,
|
||||
finished_agent="deployer",
|
||||
)
|
||||
|
||||
assert res.advanced is False
|
||||
assert res.rolled_back_to == "development"
|
||||
assert _stage(task_id) == "development"
|
||||
jobs = _jobs()
|
||||
assert len(jobs) == 1
|
||||
assert jobs[0]["agent"] == "developer"
|
||||
assert res.qg_name == "check_security_gate"
|
||||
# The deployer-authored Plane comment + the QG-failure notification fired.
|
||||
assert stage_engine.plane_add_comment.called
|
||||
assert stage_engine.notify_qg_failure.called
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-17 — the rollback task_desc carries the verbatim findings (ORCH-046).
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc17_task_desc_has_verbatim_findings(monkeypatch, tmp_path):
|
||||
"""TC-17: the re-launched developer's task_desc embeds the verbatim finding
|
||||
substance (not just a link), following the ORCH-046 pattern."""
|
||||
reason = "2 secret(s): aws-access-key in src/config.py:12"
|
||||
_qg_with_security(monkeypatch, _fail(reason))
|
||||
task_id = _make_task("deploy-staging", repo="enduro-trails")
|
||||
|
||||
# Isolate the worktree base under tmp_path so this test never touches the real
|
||||
# shared /repos/_wt host path (PermissionError in CI). Mirrors the pattern in
|
||||
# tests/test_git_worktree.py / test_merge_gate.py.
|
||||
from src import git_worktree
|
||||
monkeypatch.setattr(git_worktree.settings, "worktrees_dir", str(tmp_path / "_wt"))
|
||||
|
||||
# Seed a real 17-security-report.md in the worktree so extract_security_findings
|
||||
# has a verbatim body to excerpt.
|
||||
wt = stage_engine.get_worktree_path("enduro-trails", _BRANCH)
|
||||
report_dir = os.path.join(wt, "docs", "work-items", "ORCH-022")
|
||||
os.makedirs(report_dir, exist_ok=True)
|
||||
with open(os.path.join(report_dir, "17-security-report.md"), "w", encoding="utf-8") as f:
|
||||
f.write(
|
||||
"---\nsecurity_status: FAIL\nsecrets_found: 1\n---\n"
|
||||
"# Security Report — ORCH-022\n\n"
|
||||
"## Verdict\n1 secret(s): aws-access-key in src/config.py:12\n\n"
|
||||
"## Secrets\n- `src/config.py:12` — aws-access-key (match `AKIA…YZ`)\n\n"
|
||||
"## Dependencies (blocking)\n- None\n"
|
||||
)
|
||||
|
||||
advance_stage(
|
||||
task_id, "deploy-staging", "enduro-trails", "ORCH-022", _BRANCH,
|
||||
finished_agent="deployer",
|
||||
)
|
||||
|
||||
contents = _job_contents()
|
||||
assert len(contents) == 1
|
||||
desc = contents[0]
|
||||
# The verbatim reason AND the excerpted finding line are present.
|
||||
assert "aws-access-key in src/config.py:12" in desc
|
||||
assert "src/config.py:12" in desc
|
||||
# Plus the link to the full artefact.
|
||||
assert "17-security-report.md" in desc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-18 — after MAX_DEVELOPER_RETRIES -> block + Telegram, no bounce.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc18_retry_cap_blocks_and_alerts(monkeypatch):
|
||||
"""TC-18: after MAX_DEVELOPER_RETRIES developer attempts -> set_issue_blocked +
|
||||
Telegram alert; no infinite bounce (no new developer job)."""
|
||||
_qg_with_security(monkeypatch, _fail("blocking CVE"))
|
||||
task_id = _make_task("deploy-staging", repo="enduro-trails")
|
||||
_add_developer_runs(task_id, stage_engine.MAX_DEVELOPER_RETRIES)
|
||||
|
||||
res = advance_stage(
|
||||
task_id, "deploy-staging", "enduro-trails", "ORCH-022", _BRANCH,
|
||||
finished_agent="deployer",
|
||||
)
|
||||
|
||||
assert res.rolled_back_to == "development"
|
||||
assert res.alerted is True
|
||||
assert stage_engine.set_issue_blocked.called
|
||||
assert stage_engine.send_telegram.called
|
||||
# No further developer job past the cap.
|
||||
assert _jobs() == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-19 — PASS -> the pipeline advances normally.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc19_pass_advances_normally(monkeypatch):
|
||||
"""TC-19: security_status PASS -> advance deploy-staging -> deploy with the
|
||||
deployer launched, no rollback, no QG-failure notification."""
|
||||
_qg_with_security(monkeypatch, lambda *a, **k: (True, "security clean"))
|
||||
task_id = _make_task("deploy-staging", repo="enduro-trails")
|
||||
|
||||
res = advance_stage(
|
||||
task_id, "deploy-staging", "enduro-trails", "ORCH-022", _BRANCH,
|
||||
finished_agent="deployer",
|
||||
)
|
||||
|
||||
assert res.advanced is True
|
||||
assert res.to_stage == "deploy"
|
||||
assert _stage(task_id) == "deploy"
|
||||
assert res.rolled_back_to is None
|
||||
# No noisy QG-failure notification on the happy path.
|
||||
assert not stage_engine.notify_qg_failure.called
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-21 — self-hosting safety: a FAIL never deploys / restarts prod.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc21_fail_never_triggers_deploy(monkeypatch):
|
||||
"""TC-21: on a security FAIL the gate only rolls back + enqueues developer; it
|
||||
never calls the deploy hook / restarts the prod container (self-hosting safety)."""
|
||||
_qg_with_security(monkeypatch, _fail("secret found"))
|
||||
# Spy on the self-deploy entrypoints — none must be invoked on a FAIL.
|
||||
monkeypatch.setattr(stage_engine.self_deploy, "initiate_deploy", MagicMock())
|
||||
monkeypatch.setattr(stage_engine.self_deploy, "self_deploy_applies", MagicMock(return_value=True))
|
||||
task_id = _make_task("deploy-staging", repo="orchestrator")
|
||||
|
||||
res = advance_stage(
|
||||
task_id, "deploy-staging", "orchestrator", "ORCH-022", _BRANCH,
|
||||
finished_agent="deployer",
|
||||
)
|
||||
|
||||
assert res.rolled_back_to == "development"
|
||||
# The security FAIL returns BEFORE the self-deploy block -> no deploy initiated.
|
||||
assert not stage_engine.self_deploy.initiate_deploy.called
|
||||
# Only the developer is re-enqueued; no deployer job.
|
||||
jobs = _jobs()
|
||||
assert all(j["agent"] == "developer" for j in jobs)
|
||||
Reference in New Issue
Block a user