---
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/<pid>`; `/proc/<pid>/cmdline` (NUL-разделённое → join пробелом) →
  матч паттерна; `/proc/<pid>/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/<pid>/environ`**
  (там секреты).
- **never-raise:** per-pid guard (один нечитаемый/исчезнувший `/proc/<pid>` пропускается, не роняя
  список — гонка «процесс умер между 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/<pid>/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`.
</content>
