From 7298f11064c4a2e5d5ee772494e2a9ae4847124f Mon Sep 17 00:00:00 2001 From: claude-bot Date: Mon, 15 Jun 2026 01:28:17 +0300 Subject: [PATCH] architect(ET): auto-commit from architect run_id=675 --- docs/architecture/README.md | 18 ++ ...0041-watchdog-orphan-test-process-alert.md | 95 +++++++ ...-001-watchdog-orphan-test-process-alert.md | 245 ++++++++++++++++++ .../ORCH-111/07-infra-requirements.md | 62 +++++ docs/work-items/ORCH-111/10-tech-risks.md | 38 +++ 5 files changed, 458 insertions(+) create mode 100644 docs/architecture/adr/adr-0041-watchdog-orphan-test-process-alert.md create mode 100644 docs/work-items/ORCH-111/06-adr/ADR-001-watchdog-orphan-test-process-alert.md create mode 100644 docs/work-items/ORCH-111/07-infra-requirements.md create mode 100644 docs/work-items/ORCH-111/10-tech-risks.md diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 57afe88..922d12a 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -108,6 +108,24 @@ F1b (рамка C-1: наблюдатель отделён от наблюдае `disk_watchdog` (ORCH-063, канал орка) ⇒ **нулевой дубль по построению**; sidecar покрывает провал «орк+disk_watchdog мертвы» через `orch_down`, плюс **opt-in** независимый критический потолок `host_disk_crit` (97%, `WATCHDOG_DISK_CRIT_ENABLED=false` по умолчанию) — другое событие/канал. +- **`proc_blocking` — алерт на долго живущий осиротевший тест-процесс (ORCH-111, opt-in, + [adr-0041](adr/adr-0041-watchdog-orphan-test-process-alert.md)):** закрывает слепую зону между + `agent_hung` (видит только треканые джобы по `jobs.pid`) и осиротевшими субпроцессами pytest, + которые орк запускает сам (`merge_gate.retest_branch`/`coverage_gate.measure_coverage`) и которые + при timeout-kill агента (`-9`, ORCH-109) репарентируются на tini и живут сутками, грузя CPU и валя + merge-gate re-test. Sidecar **сам** сканирует `/proc` хоста (новый коллектор + `watchdog/collectors/proc.py`, stdlib-only, read-only, never-raise→`[]`); per-entity сигнал + `("proc_blocking", pid)` active ⇔ возраст > порога **И** cmdline матчит тест-класс (дефолт `pytest`). + Анти-false-positive и отсутствие дубля с `agent_hung` — **по построению**: cmdline-скоуп + (`claude`-агент ≠ `pytest`) + порог возраста > макс. бюджета тест-прогона + (`max(merge_retest_timeout_s, coverage_run_timeout_s)`), а не хрупким кросс-namespace матчингом PID. + Алерт/recovery — через ту же `decide()`/`AlertState` (RECOVERY синтезируется для исчезнувшего + процесса). Watchdog процесс **не трогает** (только наблюдение, C-1/BR-3). **Топология:** сервису + `orchestrator-watchdog` добавлен `pid: host` (видимость хост-namespace; привилегия только у + наблюдателя, read-only, меньше уже-смонтированного `docker.sock`). Ключи `WATCHDOG_PROC_*` + (`ENABLED` дефолт **false** / `AGE_MIN`=60 / `PATTERNS`=`pytest` / `COOLDOWN_S`); дефолт-off → + нулевая регрессия. Деплой пересобирает **только** sidecar — прод `orchestrator` не рестартится. + Детали — `docs/work-items/ORCH-111/06-adr/ADR-001-watchdog-orphan-test-process-alert.md`. - **Гарантии:** never-raise (per-source/per-tick/per-send); kill-switch `WATCHDOG_ENABLED=false` → демон инертен (idle-loop, нулевой эффект на орк); строго read-only к наблюдаемому (нет start/stop/restart/exec/записи в `docker.sock`/БД/`main`) ⇒ self-hosting-безопасно (enduro не diff --git a/docs/architecture/adr/adr-0041-watchdog-orphan-test-process-alert.md b/docs/architecture/adr/adr-0041-watchdog-orphan-test-process-alert.md new file mode 100644 index 0000000..099361b --- /dev/null +++ b/docs/architecture/adr/adr-0041-watchdog-orphan-test-process-alert.md @@ -0,0 +1,95 @@ +--- +work_item: ORCH-111 +stage: architecture +author_agent: architect +status: proposed +created_at: 2026-06-15 +model_used: claude-opus-4-8 +--- + +# adr-0041: Watchdog-сигнал `proc_blocking` — алерт на долго живущий осиротевший тест-процесс + +- **Статус:** proposed +- **Дата:** 2026-06-15 +- **Задача:** ORCH-111 (bug → escalate full-cycle) +- **Детальный ADR:** `docs/work-items/ORCH-111/06-adr/ADR-001-watchdog-orphan-test-process-alert.md` +- **Парные ADR:** `adr-0033` (sidecar-watchdog F1b), `adr-0030` (`/metrics` — не трогаем), + `adr-0024` (disk-watchdog — образец), `adr-0040` (timeout-kill `-9` — источник осиротения) + +## Контекст +Sidecar-watchdog (ORCH-100, adr-0033) алертит `agent_hung`/`stage_stuck`/`container_down`/`orch_down`/ +`host_mem`/`queue_depth`/`job_failed`/`dep_down`. `agent_hung` покрывает **только** running-агент-джобы +(по `jobs.pid` из `/metrics agents[]`). Но виновные процессы инцидента ORCH-109 — это субпроцессы +pytest, которые орк запускает своим кодом (`merge_gate.retest_branch`, `coverage_gate.measure_coverage`); +при timeout-kill агента (`-9`, adr-0040) или `TimeoutExpired` внук-pytest репарентируется на PID 1 +orchestrator-контейнера (tini жнёт зомби, но **не убивает живых осиротевших**) и живёт сутками, грузя +CPU и валя merge-gate re-test. Контейнер `orchestrator-watchdog` сейчас **не видит таблицу процессов +хоста** (`network_mode: host`, но **без** `pid: host` и mount `/proc`). Между `agent_hung` (треканые +джобы) и осиротевшим процессом — слепая зона: блокирующий pytest **не порождает сигнала**. + +## Решение +Новый per-entity сигнал **`proc_blocking`** **внутри наблюдателя** (`watchdog/**`): на каждом тике +sidecar **сам** сканирует `/proc` хоста (stdlib), отбирает процессы тест-класса (cmdline матчит +паттерн, дефолт `pytest`) и при возрасте > порога (заведомо > макс. легитимного бюджета тест-прогона) +поднимает алерт через **существующую** `decision.decide()`/`AlertState` в собственный Telegram-канал +sidecar. Watchdog процесс **не трогает** (только наблюдение, C-1). Изменения строго в наблюдателе; +`src/**` / `/metrics`+`schema_version` / `STAGE_TRANSITIONS` / `QG_CHECKS` / `check_*` / +machine-verdict / схема БД — **не тронуты**. + +- **Механизм — watchdog-side `pid: host`, НЕ orch-side `/metrics`.** Решающее: orch-side путь правит + `src/metrics.py` → рестарт прод-`orchestrator` (запрет NFR-3); и слеп именно когда орк деградировал + (CPU-голодание), что противоречит C-1 (наблюдатель переживает падение наблюдаемого). Watchdog-side + читает `/proc` независимо от живости орка и не трогает контракт `/metrics`. +- **Коллектор** `watchdog/collectors/proc.py` (новый, по образцу `collectors/host.py`): stdlib-only + (`/proc/stat` btime + `SC_CLK_TCK`; `/proc//{cmdline,stat}`; возраст из starttime, CPU-время + из utime+stime — информационно); **read-only** (никогда `os.kill`/`Popen`/`/proc//environ`); + **never-raise** (per-pid skip; top → `[]`). +- **Builder** `proc_signals` (чистый, в `signals.py`): ключ `("proc_blocking", pid)`; `active` ⇔ + `age_s > proc_age_s`; detail = усечённый cmdline-фрагмент + PID + возраст + CPU-время (BR-2). +- **RECOVERY для исчезнувшего процесса (AC-6):** в `core.tick()` синтезируется `Signal(active=False)` + для `proc_blocking`-ключей, которые `alerting=True`, но исчезли из наблюдаемых → `decide()` даёт + один RECOVERY (переиспользование машины, без отдельной анти-спам-логики, FR-5). +- **Анти-false-positive и отсутствие дубля с `agent_hung` — по построению:** (1) cmdline-скоуп — + `claude`-агенты не матчат `pytest` ⇒ нулевое пересечение с `agent_hung` (NFR-4); (2) порог возраста + > макс. бюджета (`max(merge_retest_timeout_s=600, coverage_run_timeout_s=900)=900s`) ⇒ легитимный + in-budget прогон всегда ниже порога (BR-4). Кросс-namespace матчинг PID не нужен (ненадёжен). +- **Конфиг (новые `WATCHDOG_PROC_*`):** `WATCHDOG_PROC_ENABLED` (дефолт **false** — opt-in/kill-switch, + зеркало `WATCHDOG_DISK_CRIT_ENABLED`), `WATCHDOG_PROC_AGE_MIN` (дефолт `60` мин; **инвариант:** > + макс. бюджета), `WATCHDOG_PROC_PATTERNS` (CSV, дефолт `pytest`), `WATCHDOG_PROC_COOLDOWN_S` + (дефолт `1800`). Дефолт-off ⇒ коллектор не вызывается ⇒ нулевая регрессия (AC-7). +- **Топология:** `pid: host` **только** на сервисе `orchestrator-watchdog` (НЕ volume → существующий + `:ro`-тест compose зелёный; `/proc` отражает хост автоматически, отдельный mount не нужен). + Привилегия — только у наблюдателя. + +## Альтернативы +- **Orch-side `/metrics`-обогащение** — отвергнуто: рестарт прод-орка (NFR-3) + слепота при + деградации орка (C-1) + новая поверхность контракта. +- **Bind-mount `/proc:ro` вместо `pid: host`** — эквивалентная видимость/привилегия; `pid: host` + идиоматичнее (согласован с уже-`network_mode: host`). Валидная замена при предпочтении не делить + PID-namespace. +- **Расширить `agent_hung` на нетреканые процессы** — отвергнуто: дубль/смешение классов (NFR-4). +- **Реакция (kill/reap)** — вне объёма (BR-3, жёсткое ограничение): только мониторинг. +- **Дефолт-on** — отвергнуто: привилегия + риск false-positive требуют осознанного opt-in. + +## Последствия +- Закрыта слепая зона: ранний адресный алерт о CPU-голодании до того, как оно завалит merge-gate + re-test очередной задачи; работает даже при лёгшем орке. +- Строго read-only + never-raise + дефолт-off + только наблюдатель ⇒ self-hosting-безопасно (enduro не + затронут); конвейер byte-for-byte; deploy без рестарта прод-`orchestrator` (только sidecar). +- Анти-FP и no-dup — структурно (cmdline-скоуп + порог возраста), не хрупким PID-матчингом. +- Плата: расширение привилегии наблюдателя (`pid: host`, read-only, **меньше** уже-смонтированного + `docker.sock`; код читает только `/stat`+`/cmdline`, никогда `/environ`; cmdline в алерте усечена); + Linux-специфичность `/proc` (не-Linux → `[]`); новые `WATCHDOG_PROC_*` ключи в каноне тиража. +- **Топология** меняется (`pid: host`) → `07-infra-requirements.md`; **схема БД** не меняется → 08 = + N/A. Новый компонентный сигнал + привилегия → `arch:major-change`; прод-выкат через staging-гейт + sidecar, без рестарта прод-контейнера. +- **Откат:** `WATCHDOG_PROC_ENABLED=false` (мгновенный) или удаление коллектора/builder/врезок/ключей + + `pid: host` — без следов в БД/схеме/контракте `/metrics`. + +## Связи +adr-0033 (sidecar-watchdog F1b — рантайм/машина решения/независимый канал/never-raise — прямой +родитель), adr-0030 (контракт `/metrics`/`schema_version` — изолирован, не тронут), adr-0024 +(disk-watchdog — образец pure-`decide_action`/dedup/recovery + «только читает и уведомляет»), adr-0040 +(timeout-бюджеты + `-9` timeout-kill — механизм осиротения внука-pytest), adr-0037/0038 +(Lite/Bundled тираж — канон `WATCHDOG_*` + compose sidecar, NFR-5). + diff --git a/docs/work-items/ORCH-111/06-adr/ADR-001-watchdog-orphan-test-process-alert.md b/docs/work-items/ORCH-111/06-adr/ADR-001-watchdog-orphan-test-process-alert.md new file mode 100644 index 0000000..2e71b41 --- /dev/null +++ b/docs/work-items/ORCH-111/06-adr/ADR-001-watchdog-orphan-test-process-alert.md @@ -0,0 +1,245 @@ +--- +work_item: ORCH-111 +stage: architecture +author_agent: architect +status: proposed +created_at: 2026-06-15 +model_used: claude-opus-4-8 +--- + +# ADR-001: Watchdog-сигнал `proc_blocking` — алерт на долго живущий блокирующий тест-процесс + +Work Item: **ORCH-111** — watchdog должен алертить на долго живущие pytest/дочерние процессы, блокирующие конвейер +Стадия: **architecture** +Сквозная регистрация: **`docs/architecture/adr/adr-0041-watchdog-orphan-test-process-alert.md`** (кросс-каттинг: новый компонентный сигнал sidecar + изменение топологии контейнера-наблюдателя). + +## Статус +Proposed + +## Контекст + +**Установленный инцидент (BRD §1).** На прод-хосте жили >2 суток осиротевшие процессы +`python3 -m pytest tests/test_install_lite_script.py`, грузили CPU и через конкуренцию за CPU +несколько раз валили локальный merge-gate re-test (`merge_gate.retest_branch` → `re-test timeout +after 600s`) на ORCH-109. **Ни одного алерта** не поднялось — оператор узнал случайно. + +**Слепая зона (сверено по коду).** +- Sidecar-watchdog (ORCH-100, `watchdog/`, adr-0033) уже алертит `agent_hung`/`stage_stuck`/ + `container_down`/`orch_down`/`host_mem`/`queue_depth`/`job_failed`/`dep_down`. +- `agent_hung` (`watchdog/signals.py::eval_envelope`) видит **только** running-агент-джобы: читает + `agents[]` из `GET /metrics`, который `src/metrics.py::_build_agents` строит по + `db.get_running_agents()` — то есть **только по `jobs.pid`**. +- Виновные процессы — субпроцессы pytest, которые орк запускает **своим** кодом: + `merge_gate.retest_branch` (`subprocess.run(["python","-m","pytest", target, "-q"], …)`, + таймаут `settings.merge_retest_timeout_s=600`) и `coverage_gate.measure_coverage` + (`pytest --cov=src`, таймаут `settings.coverage_run_timeout_s=900`). При + `subprocess.TimeoutExpired` / timeout-kill агента (`exit_code=-9`, ORCH-109) внук-pytest + **репарентируется на PID 1** orchestrator-контейнера (tini под `init: true` — жнёт зомби, но + **не убивает живых** осиротевших) и продолжает жить. +- **Контейнер `orchestrator-watchdog` сейчас не видит таблицу процессов хоста вообще** + (`docker-compose.yml`: `network_mode: host` + read-only `docker.sock` + `/repos:ro` + `./data:ro`, + **без** `pid: host` и **без** mount `/proc`). Между `agent_hung` (видит только треканые джобы по + `jobs.pid`) и реальностью (осиротевший pytest вне `/metrics` и вне поля зрения sidecar) зияет дыра: + долго живущий pytest, реально блокирующий конвейер через CPU-голодание, **не порождает сигнала**, + пока формально ни одна стадия не `stuck`. + +**Linux-семантика, на которую опирается решение.** Хост-namespace процессов — предок всех +контейнерных: из корневого PID-namespace видны **все** процессы всех контейнеров (с хост-глобальными +PID). Контейнер с `pid: host` присоединяется к этому namespace, и его `/proc` отражает хост → видит +осиротевший pytest независимо от того, в каком контейнере он формально запущен. Это подтверждает +факт BRD «обнаружены на хосте» (оператор делал `ps aux` в корневом namespace). + +**Почему нужен ADR, а не lite-bug.** Закрытие дыры — развилка с последствиями для безопасности +(привилегии наблюдателя) и для стабильного контракта `/metrics` (`schema_version`, ORCH-099). Задача +эскалирована `escalate: full-cycle` (BRD). + +## Решение + +### Сводка +Добавить в sidecar-watchdog **новый per-entity сигнал** `proc_blocking`: на каждом тике sidecar +**сам** сканирует хостовую таблицу процессов (stdlib `/proc`), отбирает процессы тест-класса (cmdline +матчит паттерн, дефолт `pytest`), и если возраст процесса превысил порог (заведомо больший +максимального легитимного бюджета тест-прогона) — поднимает алерт через **существующую** машину +`decision.decide()` / `AlertState` в собственный Telegram-канал sidecar. Watchdog процесс **не +трогает** (только наблюдение). Механизм видимости — **watchdog-side** (`pid: host` на сервисе +`orchestrator-watchdog`), а **не** обогащение `/metrics` орком. Изменения — строго внутри наблюдателя +(`watchdog/**` + сервис watchdog в compose); `src/**` / `/metrics` / `STAGE_TRANSITIONS` / `QG_CHECKS` +/ `check_*` / machine-verdict / схема БД — **не тронуты**. + +### D1 — Механизм видимости: watchdog-side `/proc`-скан под `pid: host` (НЕ orch-side `/metrics`) +**Решение:** sidecar получает видимость процессов хоста через `pid: host` на сервисе +`orchestrator-watchdog` и читает `/proc` напрямую (stdlib). Отвергнут orch-side путь (обогащение +`/metrics` разделом о бесхозных процессах). + +Обоснование (по приоритету): +1. **NFR-3 (self-hosting deploy-инвариант) — решающий.** Orch-side путь правит `src/metrics.py` → + требует пересборки/**рестарта прод-контейнера `orchestrator`** → прямо запрещён NFR-3 («выкат + пересобирает/рестартит только `orchestrator-watchdog`»). Watchdog-side путь правит только + `watchdog/**` + сервис watchdog → удовлетворяет NFR-3. +2. **Резилентность / покрытие именно дыры.** Осиротевший pytest опаснее всего, когда орк уже + деградировал (CPU-голодание) или лёг. Orch-side путь в этот момент **слеп** (`/metrics` + недоступен/тормозит → раздел не доставлен), что противоречит самой причине существования sidecar + (C-1: наблюдатель переживает падение наблюдаемого). Watchdog-side читает `/proc` независимо от + живости орка. +3. **Изоляция контракта.** Watchdog-side не трогает контракт `/metrics` и `schema_version` (ORCH-099) + вовсе. + +Привязка: NFR-3, BR-1, C-1. + +### D2 — Анти-false-positive и отсутствие дубля с `agent_hung` — **по построению**, без матчинга PID +Два структурных свойства, не требующих ненадёжного кросс-namespace сопоставления PID (при `pid: host` +sidecar видит хост-глобальные PID, а `/metrics agents[].pid` — PID в namespace орка; они не совпадают): + +- **Скоуп по cmdline.** `proc_blocking` рассматривает **только** процессы, чья cmdline матчит + тест-класс (дефолт-паттерн `pytest`). Агент-процессы `claude` (которые покрывает `agent_hung`) + **никогда** не матчат `pytest` → нулевое пересечение с `agent_hung` (NFR-4 / AC-5 **по + построению**). Сами pytest-субпроцессы орк запускает своим кодом (merge/coverage-гейты), они + **никогда** не треканы как джобы → `agent_hung` их и не покрывает. Два сигнала разбивают + пространство процессов по cmdline. +- **Порог возраста > макс. легитимного бюджета.** Легитимный in-flight прогон ограничен + `merge_retest_timeout_s=600s` и `coverage_run_timeout_s=900s`. При + `WATCHDOG_PROC_AGE_MIN*60 > max(600,900)=900s` легитимный прогон **всегда** ниже порога (BR-4 / + AC-4 **по построению**). Это и есть «атрибуция активному джобу» из FR-1/FR-6: процесс в пределах + бюджета физически не может перерасти порог. Кросс-namespace матчинг PID не нужен (и ненадёжен) — + возраст namespace-агностичен. + +**Кросс-инвариант (фиксируется ADR):** дефолтный порог **обязан** превышать максимум +`max(merge_retest_timeout_s, coverage_run_timeout_s)`. Дефолт `WATCHDOG_PROC_AGE_MIN=60` (мин) = +3600s — 4× запас над 900s; меняя `merge_retest_timeout_s`/`coverage_run_timeout_s` вверх, +поднимай порог. Привязка: BR-4, NFR-4, AC-4, AC-5. + +### D3 — Коллектор `watchdog/collectors/proc.py` (stdlib `/proc`-скан) +**Создать** leaf-коллектор по образцу `collectors/host.py`. Контракт **фиксирован** (NFR-1/2/6): +- **stdlib-only:** читает `/proc/stat` (`btime` — момент загрузки) + `os.sysconf("SC_CLK_TCK")`; + итерирует числовые `/proc/`; `/proc//cmdline` (NUL-разделённое → join пробелом) → + матч паттерна; `/proc//stat` поле 22 (`starttime` в тиках) → `age_s = now - (btime + + starttime/clk_tck)`; поля 14+15 (`utime+stime`) → `cpu_s = (utime+stime)/clk_tck` (накопленное + CPU-время, **информационно** для BR-2; в активацию НЕ входит). +- **read-only:** только открытие файлов на чтение. **Запрещены** `os.kill`, отправка сигналов, + `subprocess.Popen/run`, любые мутации ФС/процессов. **Никогда не читает `/proc//environ`** + (там секреты). +- **never-raise:** per-pid guard (один нечитаемый/исчезнувший `/proc/` пропускается, не роняя + список — гонка «процесс умер между listdir и read» нормальна); top-level guard → `[]`. +- Выход — список записей `{pid, cmdline, age_s, cpu_s?, start_ticks}`; чистый разбор отделён от I/O + (тестируемо на фикстурах `/proc`-текста, без реального хоста). + +Привязка: FR-2, NFR-1, NFR-2, NFR-6, AC-8. + +### D4 — Чистый builder `proc_signals` + синтез RECOVERY для исчезнувшего процесса +**Builder** в `watchdog/signals.py` (по образцу `container_signals`/`host_signals`): по списку +кандидатов и конфигу возвращает `Signal`-объекты. +- **Ключ** — per-entity `("proc_blocking", pid)` (зеркало `("container_down", name)`). +- **active=True** ⇔ `age_s > cfg.proc_age_s` (cmdline уже отфильтрована коллектором по паттерну). +- **title/detail** — действенные (RU, в стиле существующих): фрагмент cmdline + PID + возраст (сек) + + (при наличии) CPU-время (BR-2). Cmdline-фрагмент **усечь** до ограниченной длины (анти-утечка + случайных аргументов в канал; см. риск R-2). + +**RECOVERY для исчезнувшего процесса (новый аспект, AC-6).** `container_down` авто-recovery'ится +лишь потому, что набор имён статичен (Signal строится на каждый сконфигурированный контейнер). А +`agent_hung`/`stage_stuck` emit'ят сигнал **только** когда active=True → при исчезновении сущности +**не** recovery'ятся (известное ограничение). AC-6 **требует однократный RECOVERY при исчезновении +процесса**. Решение, **переиспользующее** `decide()`/`AlertState` (FR-5 — никакой отдельной +анти-спам-логики): в `core.tick()` после построения сигналов по текущим кандидатам **синтезировать** +`Signal(active=False)` для каждого ключа в `self._states` с префиксом `"proc_blocking"`, который +`alerting=True`, но **отсутствует** в множестве текущих наблюдаемых ключей → `decide()` даёт +`RECOVERY` один раз и чистит состояние. Это per-family bookkeeping, не новая throttle-логика. +PID-recycling — редкий край: естественный цикл vanish→recovery→new-alert корректен; опциональное +усиление ключа — добавить `start_ticks` (`("proc_blocking", pid, start_ticks)`). + +Привязка: FR-1, FR-5, BR-2, BR-5, AC-6. + +### D5 — Конфиг + kill-switch (дефолт-off) +В `watchdog/config.py` добавить ключи (never-raise парсеры `_bool`/`_float`/`_csv`): + +| Ключ | Тип | Дефолт | Смысл | +|------|-----|--------|-------| +| `WATCHDOG_PROC_ENABLED` | bool | **`false`** | kill-switch / осознанный opt-in (зеркало `WATCHDOG_DISK_CRIT_ENABLED`) | +| `WATCHDOG_PROC_AGE_MIN` | float (мин) | `60` | порог возраста; **обязан** > `max(merge_retest_timeout_s, coverage_run_timeout_s)/60` (D2) | +| `WATCHDOG_PROC_PATTERNS` | CSV | `pytest` | паттерны cmdline тест-класса (substring-матч) | +| `WATCHDOG_PROC_COOLDOWN_S` | float | `1800` | per-signal cooldown (через `Signal.cooldown_s`, уже есть) | + +Derived `proc_age_s = proc_age_min*60` (как `agent_hung_s`/`stage_stuck_s`). **Дефолт-off** — потому +что `proc_blocking` требует привилегии `pid: host` (D6) и осознанного включения (BR-6/NFR-3). При +`WATCHDOG_PROC_ENABLED=false` коллектор в `core.tick()` **не вызывается** (гейт как у `_collect_disk` +на `disk_crit_enabled`) → нулевой оверхед и нулевая регрессия (AC-7). + +Привязка: FR-3, BR-6, NFR-1, AC-7. + +### D6 — Топология: `pid: host` **только** на `orchestrator-watchdog` +Аддитивная однострочная правка сервиса `orchestrator-watchdog` в `docker-compose.yml`: `pid: host`. +Под `pid: host` контейнерный `/proc` автоматически отражает хостовый namespace — **отдельный +mount `/proc` не нужен**. `pid: host` — НЕ volume, поэтому существующий тест +`tests/watchdog/test_compose_service.py::test_host_paths_mounted_read_only` (требует `:ro` на каждом +**volume**) остаётся зелёным; разработчик добавляет позитивный тест на наличие `pid: host`. +**Привилегия — только у наблюдателя**, обоснование/риски — `07-infra-requirements.md` + R-2 ниже. +Деплой: пересобрать/рестартить **только** `orchestrator-watchdog`; прод `orchestrator` **не** +трогается (NFR-3). Для Lite/Bundled (ORCH-102/103) `pid: host` становится частью канонического +compose sidecar; сигнал дефолт-off → нулевое изменение поведения тиражных инсталляций. + +Привязка: NFR-3, BR-1, BR-6, AC-9. + +### D7 — Диспетч и инварианты конвейера +Новый сигнал диспетчеризуется в `core.tick()` тем же путём `decision.decide(...)` + `self._states[key]` ++ `self._send(...)`: ALERT на пересечении порога, REALERT по cooldown, RECOVERY при исчезновении +(D4). Никакой отдельной анти-спам-логики (FR-5). Инварианты (AC-9, byte-for-byte): +`STAGE_TRANSITIONS` / `QG_CHECKS` / `check_*` / machine-verdict (`verdict:`/`result:`/`deploy_status:`/ +`staging_status:`/`security_status:`/`coverage_status:`) / схема БД / `/metrics`+`schema_version` +— **не тронуты**. Watchdog — наблюдатель вне процесса орка и вне Quality Gates (как +`disk_watchdog`/`reaper`/`reconciler`). + +## Альтернативы +- **(b) Orch-side обогащение `/metrics` разделом о бесхозных процессах** — отвергнуто: правит + `src/**` → рестарт прод-`orchestrator` (нарушение NFR-3); слеп именно когда орк деградировал + (нарушение C-1); новая поверхность контракта `/metrics`. (D1.) +- **Bind-mount хостового `/proc:ro` вместо `pid: host`** — даёт ту же видимость через read-only + volume (паттерн node_exporter `--path.procfs`), но привилегия **эквивалентна** (`/proc//environ` + читаем при обоих) и `/proc`-mount имеет краевые особенности; `pid: host` идиоматичнее и согласован + с уже-host-network-наблюдателем. Остаётся валидной заменой, если оператор предпочитает не делить + PID-namespace. (D6.) +- **Расширить `agent_hung` на нетреканые процессы** — отвергнуто: дубль/смешение классов (NFR-4); у + sidecar нет `jobs.pid` для нетреканых, а кросс-namespace PID не совпадают. +- **Реакция (kill/reap осиротевших)** — вне объёма (жёсткое ограничение заказчика BR-3): задача — + только мониторинг; reap — отдельная задача (ремедиация). +- **Дефолт-on сигнала** — отвергнуто: привилегия `pid: host` + риск false-positive требуют осознанного + включения; зеркало `WATCHDOG_DISK_CRIT_ENABLED=false`. + +## Последствия +- **+** Закрыта дыра наблюдаемости: долго живущий блокирующий pytest даёт ранний адресный алерт + (PID/cmdline/возраст), даже если орк лёг и ни одна стадия формально не `stuck`. +- **+** Строго read-only + never-raise + дефолт-off + изменения только в наблюдателе ⇒ + self-hosting-безопасно; enduro не затронут; конвейер byte-for-byte; deploy без рестарта прод-орка + (NFR-3). +- **+** Анти-false-positive и отсутствие дубля с `agent_hung` — структурно (cmdline-скоуп + порог + возраста), а не хрупким матчингом PID. +- **−** Расширение привилегии наблюдателя (`pid: host`): sidecar видит таблицу процессов хоста. + Митигейшн: привилегия read-only и **меньше**, чем уже смонтированный `docker.sock` (полная + интроспекция контейнеров); код читает **только** `/stat`+`/cmdline`, никогда `/environ`; сигнал + дефолт-off; cmdline в алерте усечена. (R-1/R-2.) +- **−** Новая поверхность совместимости с `/proc`-форматом (Linux-специфично); на не-Linux/битом + `/proc` коллектор → `[]` (один сигнал тих). Митигейшн: чистый разбор + фикстуры. +- **−** Канон тиража: при добавлении ключей/правке compose — синхронизировать в **том же PR** + `.env.watchdog.example` ↔ блок `WATCHDOG_*` `.env.example`, `docs/deployment/LITE_SETUP.md`, + `docs/architecture/README.md` (NFR-5; key-sync `tests/test_lite_setup_doc.py`). +- **Масштаб:** новый компонентный сигнал + изменение топологии/привилегий наблюдателя → + рекомендуется лейбл **`arch:major-change`**; прод-выкат — через staging-эквивалент sidecar + (smoke на staging-хосте), без рестарта прод-`orchestrator`. +- **Откат:** `WATCHDOG_PROC_ENABLED=false` (мгновенный, привилегия дремлет) или удаление + `watchdog/collectors/proc.py` + builder/врезки + ключей + `pid: host` — без следов в БД/схеме/ + контракте `/metrics`. + +## Ссылки +- BRD: `docs/work-items/ORCH-111/01-brd.md` +- TRZ: `docs/work-items/ORCH-111/02-trz.md` +- Acceptance: `docs/work-items/ORCH-111/03-acceptance-criteria.md` +- Инфра: `docs/work-items/ORCH-111/07-infra-requirements.md` +- Риски: `docs/work-items/ORCH-111/10-tech-risks.md` +- Сквозной ADR: `docs/architecture/adr/adr-0041-watchdog-orphan-test-process-alert.md` +- Связанные ADR: `adr-0033` (sidecar-watchdog F1b — машина `decide`/`AlertState`/never-raise), + `adr-0030` (контракт `/metrics`/`schema_version` — НЕ трогаем), `adr-0024` (disk-watchdog — образец + pure-решающей функции + dedup), `adr-0040` (ORCH-109 timeout-kill `-9` — источник осиротения). +- Сверено по коду: `watchdog/{core,signals,config,decision,notify}.py`, + `watchdog/collectors/{host,orch}.py`, `docker-compose.yml` (сервис `orchestrator-watchdog`), + `src/merge_gate.py::retest_branch`, `src/coverage_gate.py::measure_coverage`, + `src/config.py` (`merge_retest_timeout_s=600`, `coverage_run_timeout_s=900`), + `tests/watchdog/test_compose_service.py`, `tests/test_lite_setup_doc.py`. + diff --git a/docs/work-items/ORCH-111/07-infra-requirements.md b/docs/work-items/ORCH-111/07-infra-requirements.md new file mode 100644 index 0000000..913e3b3 --- /dev/null +++ b/docs/work-items/ORCH-111/07-infra-requirements.md @@ -0,0 +1,62 @@ +--- +work_item: ORCH-111 +stage: architecture +author_agent: architect +status: proposed +created_at: 2026-06-15 +model_used: claude-opus-4-8 +--- + +# 07 — Инфра-требования: ORCH-111 — watchdog-алерт на долго живущий тест-процесс + +Work Item: **ORCH-111** · Repo: **orchestrator** · Стадия: architecture + +> Применимо: меняется топология контейнера-наблюдателя (`pid: host`) и добавляются env-ключи +> watchdog. Решение — `06-adr/ADR-001` + сквозной `adr-0041`. + +## I-1. Топология / окружения +- **Изменение:** сервису `orchestrator-watchdog` в `docker-compose.yml` добавляется **`pid: host`** + (ADR-001 D6). Под `pid: host` контейнерный `/proc` отражает корневой PID-namespace хоста → + watchdog видит таблицу процессов хоста (включая осиротевшие pytest, репарентированные на tini + orchestrator-контейнера). **Отдельный mount `/proc` не требуется** (procfs зависит от namespace + читателя). +- `pid: host` — **НЕ volume** → существующий тест `tests/watchdog/test_compose_service.py:: + test_host_paths_mounted_read_only` (требует `:ro` на каждом volume) остаётся зелёным; разработчик + добавляет позитивный тест на наличие `pid: host`. +- Привилегия — **только у наблюдателя** (`orchestrator-watchdog`). Прод `orchestrator` и + `orchestrator-staging` **не** меняются. Прочие тома/порты/сеть watchdog — без изменений + (`network_mode: host`, `docker.sock:ro`, `/repos:ro`, `./data:ro`, `mem_limit: 128m`). +- **Тираж (Lite/Bundled, ORCH-102/103):** `pid: host` входит в канонический compose sidecar; сигнал + дефолт-off → нулевое изменение поведения у тиражных инсталляций. + +## I-2. Переменные окружения / секреты +- **Новые ключи** (ADR-001 D5), парсеры never-raise: + | Ключ | Дефолт | Смысл | + |------|--------|-------| + | `WATCHDOG_PROC_ENABLED` | `false` | kill-switch / осознанный opt-in | + | `WATCHDOG_PROC_AGE_MIN` | `60` | порог возраста (мин); **обязан** > `max(merge_retest_timeout_s=600, coverage_run_timeout_s=900)/60` | + | `WATCHDOG_PROC_PATTERNS` | `pytest` | CSV паттернов cmdline (substring-матч) | + | `WATCHDOG_PROC_COOLDOWN_S` | `1800` | per-signal cooldown | +- **Канон тиража (NFR-5 / AC-10):** ключи добавить в **том же PR** в `.env.watchdog.example` **И** в + блок `WATCHDOG_*` `.env.example` (равенство множеств держит `tests/test_lite_setup_doc.py` + TC-02b) + описать в `docs/deployment/LITE_SETUP.md` и `docs/architecture/README.md`. +- **Секреты:** новых нет. Канал алертов — существующий `WATCHDOG_TG_*` (свой бот sidecar, C-1). +- **На прод-хосте (разово, человек):** в `.env.watchdog` выставить `WATCHDOG_PROC_ENABLED=true` + (включение сигнала; без этого — дремлет). + +## I-3. Деплой / рестарт +- **Self-hosting инвариант (NFR-3):** выкат пересобирает/рестартит **только** контейнер + `orchestrator-watchdog` (`docker compose up -d --build orchestrator-watchdog`). Прод-контейнер + `orchestrator` **НЕ** перезапускается (иначе встанет конвейер всех проектов, включая enduro). +- Прод-выкат sidecar — через staging-эквивалент (smoke на staging-хосте перед прод): проверить, что + под `pid: host` коллектор видит процессы, сигнал поднимается на синтетическом старом pytest и молчит + при дефолт-off. +- **Откат:** `WATCHDOG_PROC_ENABLED=false` (мгновенный; привилегия дремлет) либо снятие `pid: host` + + рестарт только sidecar. + +## I-4. CI/CD +- Без изменений `.gitea/workflows/`. Новые тесты — `tests/watchdog/test_proc_blocking_signal.py`, + `tests/watchdog/test_tick_proc_blocking_integration.py` (TRZ §2) — исполняются существующим + `pytest tests/`. Key-sync (`test_lite_setup_doc.py`) и compose-тесты watchdog должны остаться + зелёными. + diff --git a/docs/work-items/ORCH-111/10-tech-risks.md b/docs/work-items/ORCH-111/10-tech-risks.md new file mode 100644 index 0000000..2d071e6 --- /dev/null +++ b/docs/work-items/ORCH-111/10-tech-risks.md @@ -0,0 +1,38 @@ +--- +work_item: ORCH-111 +stage: architecture +author_agent: architect +status: proposed +created_at: 2026-06-15 +model_used: claude-opus-4-8 +--- + +# 10 — Технические риски: ORCH-111 — watchdog-алерт на долго живущий тест-процесс + +Work Item: **ORCH-111** · Repo: **orchestrator** · Стадия: architecture + +> Информационный (гейтом не парсится). Риски реализации и их митигейшн. + +## Реестр рисков + +| ID | Риск | Вер. | Влия. | Митигейшн | +|----|------|------|-------|-----------| +| TR-1 | **Расширение привилегии наблюдателя** (`pid: host`): sidecar видит таблицу процессов всего хоста (все контейнеры). | Сред. | Сред. | Привилегия **read-only** и **меньше**, чем уже-смонтированный `docker.sock` (полная интроспекция контейнеров); код читает **только** `/proc//{stat,cmdline}`, **никогда** `/proc//environ`; сигнал дефолт-off; обоснование в ADR-001 D1/D6 + adr-0041. (NFR-2/AC-3) | +| TR-2 | **Утечка секретов через cmdline** в Telegram-алерт (если тест-команда содержит чувствительный аргумент). | Низ. | Сред. | Скоуп паттерна — `pytest` (не принимает секретов в аргументах); cmdline в detail **усекать** до ограниченного фрагмента; канал — приватный бот оператора (C-1). | +| TR-3 | **Ложные срабатывания** на легитимном длинном прогоне → спам. | Низ. | Сред. | Порог возраста **обязан** > `max(merge_retest_timeout_s=600, coverage_run_timeout_s=900)`; дефолт `WATCHDOG_PROC_AGE_MIN=60` мин (4× запас); cmdline-скоуп; дедуп/cooldown через `decision.decide`/`AlertState` (BR-4/BR-5, по построению D2). | +| TR-4 | **Кросс-namespace PID не совпадают** (`pid: host` даёт хост-глобальные PID; `/metrics agents[].pid` — namespace орка) → ненадёжная атрибуция «процесс активного джоба». | Сред. | Низ. | Атрибуция **не** через PID, а через порог возраста (namespace-агностичен) + cmdline-скоуп; дубль с `agent_hung` исключён по построению (claude ≠ pytest). ADR-001 D2. | +| TR-5 | **Отсутствие RECOVERY** для исчезнувшего процесса (динамический per-entity ключ — как у `agent_hung`/`stage_stuck`, которые не recovery'ятся при пропадании сущности). | Сред. | Низ. | Синтез `Signal(active=False)` для `proc_blocking`-ключей, alerting=True но исчезнувших из наблюдаемых → один RECOVERY через `decide()` (ADR-001 D4, AC-6). Покрыть интеграционным тестом tick→recovery. | +| TR-6 | **PID-recycling**: PID переиспользован после смерти орфана → ложная пара recovery+new-alert. | Низ. | Низ. | Естественный цикл vanish→recovery→new-alert корректен; опционально усилить ключ `("proc_blocking", pid, start_ticks)`. | +| TR-7 | **never-raise регресс**: гонка «процесс умер между `listdir(/proc)` и `read`» или битый `/proc` роняет тик. | Низ. | Выс. | Per-pid guard (skip), top-level guard → `[]`; чистый разбор отделён от I/O и покрыт фикстурами; AC-8. | +| TR-8 | **Дрейф канона тиража**: ключи добавлены в код, но не синхронизированы в `.env.example`/`.env.watchdog.example`/`LITE_SETUP.md`. | Сред. | Низ. | Key-sync `tests/test_lite_setup_doc.py` (TC-02b) красит PR; норматив NFR-5 «обновить в том же PR»; reviewer-ось ORCH-079 (доки). | +| TR-9 | **Не-Linux / нет `/proc`** (тиражная инсталляция на не-Linux) → сигнал не работает. | Низ. | Низ. | Коллектор → `[]` (один сигнал тих, тик жив); сигнал дефолт-off; Linux-специфичность задокументирована (как CPU-liveness `agent_hung`). | + +## Сводный вывод +Доминирующий класс — **безопасность наблюдателя** (TR-1/TR-2: привилегия `pid: host` + видимость +cmdline). Остаточный риск для прод-конвейера **низкий**: все изменения строго в наблюдателе +(`watchdog/**` + сервис watchdog), read-only, never-raise, дефолт-off; `src/**` / `/metrics` / +`STAGE_TRANSITIONS` / `QG_CHECKS` / `check_*` / схема БД — не тронуты; выкат не рестартит прод-`orchestrator` +(NFR-3). Эскалация: рекомендуется лейбл **`arch:major-change`** (изменение топологии/привилегий +наблюдателя + новый компонентный сигнал). Возврат в анализ **не требуется** — ТЗ удовлетворяется без +нарушения принципов архитектуры (всё в Docker на одном сервере, stdlib-only, без новых зависимостей). +