From a0526e1def9aed320af0b895ccf7bbd8221c6b2c Mon Sep 17 00:00:00 2001 From: Slava Date: Mon, 15 Jun 2026 00:58:06 +0300 Subject: [PATCH 01/14] docs: init ORCH-111 business request --- docs/work-items/ORCH-111/00-business-request.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 docs/work-items/ORCH-111/00-business-request.md diff --git a/docs/work-items/ORCH-111/00-business-request.md b/docs/work-items/ORCH-111/00-business-request.md new file mode 100644 index 0000000..2054302 --- /dev/null +++ b/docs/work-items/ORCH-111/00-business-request.md @@ -0,0 +1,7 @@ +# Business Request: BUG: watchdog must alert on long-lived pytest/child processes that block the pipeline + +Work Item ID: ORCH-111 + +## Description + +TBD From 44adcba389df2f224bcf8b5f20377876ef1dcc6c Mon Sep 17 00:00:00 2001 From: claude-bot Date: Mon, 15 Jun 2026 01:05:57 +0300 Subject: [PATCH 02/14] analyst(ET): auto-commit from analyst run_id=674 --- docs/work-items/ORCH-111/01-brd.md | 145 ++++++++++++++++++ docs/work-items/ORCH-111/02-trz.md | 122 +++++++++++++++ .../ORCH-111/03-acceptance-criteria.md | 129 ++++++++++++++++ docs/work-items/ORCH-111/04-test-plan.yaml | 102 ++++++++++++ 4 files changed, 498 insertions(+) create mode 100644 docs/work-items/ORCH-111/01-brd.md create mode 100644 docs/work-items/ORCH-111/02-trz.md create mode 100644 docs/work-items/ORCH-111/03-acceptance-criteria.md create mode 100644 docs/work-items/ORCH-111/04-test-plan.yaml diff --git a/docs/work-items/ORCH-111/01-brd.md b/docs/work-items/ORCH-111/01-brd.md new file mode 100644 index 0000000..1d3a58f --- /dev/null +++ b/docs/work-items/ORCH-111/01-brd.md @@ -0,0 +1,145 @@ +--- +work_item: ORCH-111 +stage: analysis +author_agent: analyst +status: ready-for-review +created_at: 2026-06-15 +model_used: claude-opus-4-8 +escalate: full-cycle +--- + +# 01 — BRD (бизнес-требования): ORCH-111 — watchdog должен алертить на долго живущие pytest/дочерние процессы, блокирующие конвейер + +Work Item: **ORCH-111** · Repo: **orchestrator** · Стадия: analysis · Трек: **Bug → эскалация в полный цикл** + +> **Эскалация (`escalate: full-cycle`).** Задача пришла как баг (`BUG:` в заголовке), но **не** +> является дешёвым багфиксом: закрытие пробела требует **архитектурного решения** — у sidecar-watchdog +> сейчас **нет видимости процессов хоста вообще** (`network_mode: host`, но без `pid: host` и без +> bind-mount `/proc`). Выбор механизма наблюдения (расширение привилегий sidecar vs обогащение +> контракта `/metrics` орком) — это развилка с последствиями для безопасности и стабильного контракта +> `/metrics` (schema_version, ORCH-099). Поэтому пакет — **полный** (а не lite-bug), и задача +> помечена `escalate: full-cycle`: нужен прогон стадии `architecture` + ADR (механизм видимости, +> эвристика детекции, привилегии/безопасность). Оператор снимает багфикс-трек эндпоинтом +> `POST /bug-fast-track/escalate?work_item=ORCH-111` (ADR-001 D5, ORCH-019). + +## 1. Бизнес-контекст и проблема + +### 1.1 Симптом (установленный факт) +На хосте прода были обнаружены старые зависшие процессы `python3 -m pytest tests/test_install_lite_script.py`, +которые жили **более 2 суток**, грузили CPU и мешали локальному merge-gate re-test. Из-за конкуренции +за CPU задача **ORCH-109 несколько раз упиралась** в `re-test timeout after 600s` +(`merge_gate.retest_branch`). Сами эти процессы **не были подняты как отдельный alert** watchdog'а — +оператор узнал о них случайно. + +### 1.2 Локализация +- **Мониторинг-мозг** — sidecar-watchdog (ORCH-100, каталог `watchdog/`, сервис `orchestrator-watchdog`). + Он уже алертит на `stage_stuck` (стадия задачи застряла) и `container_down` (контейнер не в норме), + а также `agent_hung`, `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, которые орк запускает в worktree: + `merge_gate.retest_branch` (`python -m pytest `) и `coverage_gate.measure_coverage` + (`pytest --cov=src`). При `subprocess.TimeoutExpired` Python убивает прямого ребёнка, но + **внуки/репарентированные процессы переживают**; а если сам агент-процесс убит по таймауту + (`exit_code=-9`, ORCH-109) — его дочерний pytest **репарентируется на PID 1** и продолжает жить. + +### 1.3 Причина (root cause) +Между двумя наблюдателями зияет **слепая зона**: `agent_hung` видит лишь *отслеживаемые* агент-джобы +(по `jobs.pid`), а **осиротевшие/внебюджетные тестовые субпроцессы** (внуки pytest, репарентированные +на PID 1) **не присутствуют ни в `/metrics`, ни в поле зрения sidecar** — у контейнера watchdog нет +доступа к таблице процессов хоста. Поэтому долго живущий pytest, реально блокирующий конвейер через +CPU-голодание merge-gate, **не порождает ни одного сигнала**, пока формально ни одна стадия задачи не +«застряла». + +## 2. Объём (scope) + +### В объёме +- Новый **отдельный класс алерта** watchdog'а: «долго живущий тестовый/дочерний процесс блокирует + конвейер» — поднимается, даже если стадия задачи формально не `stuck`. +- Детекция долго живущих процессов тест-класса (pytest и родственные субпроцессы гейтов), переживших + свой бюджет/осиротевших, на хосте прода. +- Актуализация конфиг-канона watchdog (`.env.watchdog.example` / блок `WATCHDOG_*` в `.env.example`) + и наблюдаемости. + +### Вне объёма +- **Любая реакция на процесс** (kill/SIGTERM/cleanup/reap/перезапуск). Задача — **только мониторинг + + сигнализация** (явное ограничение заказчика). Автоматический reap осиротевших процессов — **отдельная + задача**. +- Изменение конвейера: `STAGE_TRANSITIONS` / `QG_CHECKS` / `check_*` / machine-verdict / схема БД — **не + трогаются** (watchdog — наблюдатель вне процесса орка и вне Quality Gates). +- Расширение `agent_hung` на нетреканые процессы (это другой класс сигнала; дубль запрещён — см. NFR-4). +- Снятие первопричины осиротения процессов в самом орке (надёжный reap внуков pytest) — ценно, но это + **ремедиация**, она вне объёма этой задачи. + +## 3. Заинтересованные стороны +- **Заказчик/оператор прода** (Слава) — получает ранний сигнал о CPU-голодании ещё до того, как оно + завалит merge-gate re-test очередной задачи. +- **Self-hosting конвейер orchestrator** — страдает напрямую (инцидент ORCH-109). +- **Тиражные инсталляции (Lite/Bundled, ORCH-102/103)** — sidecar входит в дефолтный комплект; новый + сигнал должен укладываться в канон конфига и не ломать тираж. +- **Принимает результат** — reviewer/tester + оператор (smoke на staging-эквиваленте sidecar). + +## 4. Бизнес-требования (BR) +- **BR-1** — Watchdog поднимает **отдельный, узнаваемый** alert, когда на хосте обнаружен долго живущий + процесс тест-класса (pytest и его субпроцессы), возраст которого превышает настраиваемый порог — + **независимо** от того, застряла ли формально какая-либо стадия задачи. +- **BR-2** — Текст алерта **действенно идентифицирует** виновника: фрагмент командной строки, PID, + возраст процесса и (при наличии) доля CPU — чтобы оператор мог сразу разобраться и вручную вмешаться. +- **BR-3** — **Только мониторинг + сигнализация.** Watchdog **не убивает / не останавливает / не шлёт + сигналы** процессу и не выполняет иную ремедиацию (жёсткое ограничение заказчика, рамка C-1 + «наблюдатель строго read-only к наблюдаемому», ORCH-100). +- **BR-4** — **Без ложных срабатываний** на легитимных in-flight прогонах: тестовый процесс, + принадлежащий **активному отслеживаемому** агенту/гейту в пределах его бюджета, alert поднимать + **не должен**. +- **BR-5** — Анти-спам и recovery как у прочих сигналов: один alert на пересечение порога, throttled + re-alert по cooldown, однократный recovery при исчезновении процесса (переиспользовать + `watchdog/decision.py::decide` + `AlertState`). +- **BR-6** — Сигнал под **kill-switch** и управляется конфигом (порог возраста, cooldown, область). + Дефолт выбирается так, чтобы включение было **осознанным** и **self-hosting-безопасным** (см. NFR-3). + +## 5. Нефункциональные требования (NFR) +- **NFR-1 (надёжность)** — **never-raise** на всех новых путях (per-source / per-tick / per-send), как + и весь watchdog: сбой коллектора процессов деградирует ОДИН сигнал, а не роняет тик. +- **NFR-2 (read-only)** — строго наблюдение: **ни одного** управляющего действия над процессами/хостом + (нет `kill`/`signal`/`Popen`/записи). Соответствует C-1 (observer separated from observed). +- **NFR-3 (self-hosting безопасность)** — выкат изменения **не перезапускает** прод-контейнер + `orchestrator` (встанет конвейер всех проектов): пересобирается/рестартится **только** контейнер + `orchestrator-watchdog`. Если механизм требует расширения привилегий sidecar (напр. `pid: host`) — + это привилегия **только наблюдателя**, обоснование и риски — задача архитектора (ADR). +- **NFR-4 (без дубля)** — новый сигнал **не пересекается** с `agent_hung` (тот уже покрывает + отслеживаемые агент-джобы): новый сигнал закрывает ровно пробел «нетреканый/осиротевший процесс». +- **NFR-5 (канон тиража)** — при изменении compose / ключей `.env.watchdog` обновить в **том же PR**: + `.env.watchdog.example`, блок `WATCHDOG_*` в `.env.example`, `docs/deployment/LITE_SETUP.md` и + `docs/architecture/README.md` (норматив сопровождения ORCH-102 NFR-5; key-set-sync тест). +- **NFR-6 (стек)** — sidecar остаётся **stdlib-only** (C-3, ORCH-100): без новых сторонних зависимостей. + +## 6. Допущения и ограничения +- **Ключевое архитектурное допущение (для архитектора):** у контейнера `orchestrator-watchdog` сейчас + **нет** видимости процессов хоста (`network_mode: host`, но без `pid: host` и без mount `/proc`). + Закрытие пробела требует выбора механизма — **развилка, решаемая ADR**, не аналитиком. Кандидаты + (перечислены как материал для решения, **без навязывания**): (a) расширение привилегий sidecar — + `pid: host` либо read-only mount хостового `/proc`, затем stdlib-скан таблицы процессов; (b) + обогащение `/metrics` орком новым read-only разделом о «бесхозных» тест-субпроцессах (орк видит свой + PID-namespace), который sidecar лишь читает. У каждого — свои trade-off'ы (привилегии vs контракт + `/metrics`). +- `/metrics` — **версионированный контракт** (`schema_version`, ORCH-099): если выбран путь (b), + аддитивные изменения **не бампят** версию (sidecar обязан толерировать). +- Порог возраста для детекции **должен превышать** максимальный легитимный бюджет тест-прогона + (`merge_retest_timeout_s` ≈ 600s, `coverage_run_timeout_s`), чтобы нормальный прогон **никогда** не + алертил, а 2-суточный осиротевший pytest — гарантированно (анти-false-positive, материал для ADR). +- enduro-trails не затронут: watchdog наблюдает хост/орк self-hosting; сигнал config-gated. + +## 7. Критерии успеха +Watchdog при наличии долго живущего pytest/дочернего процесса, грузящего CPU, **поднимает отдельный +alert** в свой Telegram-канал (с PID/cmd/возрастом), **не трогая** процесс; при отсутствии такого +процесса (или выключенном флаге) — молчит; нормальный тест-прогон под активным джобом **не** триггерит +ложный alert. Детальные PASS/FAIL — `03-acceptance-criteria.md`. + +## 8. Риски +- **Ложные срабатывания** на легитимном длинном прогоне → спам в канал (митигируется порогом > + макс. бюджета + корреляцией с активным джобом). +- **Расширение привилегий sidecar** (если выбран `pid: host`/`/proc`-mount) → увеличение поверхности + безопасности наблюдателя (требует явного обоснования в ADR; дефолт-off). +- **Дубль с `agent_hung`** при небрежной реализации (NFR-4). +- Детали и владельцы рисков — `10-tech-risks.md` (заполняет архитектор). diff --git a/docs/work-items/ORCH-111/02-trz.md b/docs/work-items/ORCH-111/02-trz.md new file mode 100644 index 0000000..50e27d5 --- /dev/null +++ b/docs/work-items/ORCH-111/02-trz.md @@ -0,0 +1,122 @@ +--- +work_item: ORCH-111 +stage: analysis +author_agent: analyst +status: ready-for-review +created_at: 2026-06-15 +model_used: claude-opus-4-8 +--- + +# 02 — ТЗ (TRZ): ORCH-111 — alert на долго живущие pytest/дочерние процессы в watchdog + +Work Item: **ORCH-111** · Repo: **orchestrator** · Стадия: analysis + +> ТЗ описывает **конкретные изменения к реализации**, выведенные из BRD и фактического кода. +> Архитектурное обоснование/решения — задача архитектора (06-adr). В частности, **выбор механизма +> видимости процессов хоста** (см. §2 «развилка») — за ADR; ниже зафиксированы **требования и +> ограничения**, а не способ реализации. + +## 1. Сводка изменения +Добавить в sidecar-watchdog (`watchdog/`) **новый сигнал** класса «долго живущий блокирующий +тест/дочерний процесс» (рабочее имя ключа — `proc_blocking`; финальное имя утверждает разработчик/ADR). +Сигнал активен, когда на хосте есть процесс тест-класса (pytest и его субпроцессы), чей возраст +превысил настраиваемый порог и который **не атрибутируется активному отслеживаемому джобу**. Сигнал +проходит через **существующую** машину `decide()`/`AlertState` (анти-спам/recovery) и публикуется в +собственный Telegram-канал sidecar. Watchdog при этом **не трогает** процесс (BR-3/NFR-2). Это +изменение **внутри наблюдателя**: машина стадий орка и Quality Gates не затрагиваются. + +## 2. Задействованные модули / пути + +> **Развилка механизма (решает архитектор, ADR).** Часть путей **условна** и зависит от выбранного +> механизма видимости процессов. Ниже помечено явно. + +| Путь | Действие | +|------|----------| +| `watchdog/signals.py` | изменить — чистый builder нового сигнала `proc_blocking` (по образцу `host_signals`/`container_signals`) | +| `watchdog/config.py` | изменить — новые ключи `WATCHDOG_PROC_*` (enable/порог возраста/паттерны/cooldown); never-raise парсеры | +| `watchdog/core.py` | изменить — врезка коллектора процессов + диспетч нового сигнала в `tick()` (per-source guard) | +| `watchdog/collectors/proc.py` | **создать** — коллектор списка процессов-кандидатов (механизм — по ADR); never-raise → `[]` | +| `.env.watchdog.example` | изменить — задокументировать новые `WATCHDOG_PROC_*` ключи (канон) | +| `.env.example` (блок `WATCHDOG_*`) | изменить — key-set-sync с `.env.watchdog.example` (тест `test_lite_setup_doc`/key-sync) | +| `tests/watchdog/test_proc_blocking_signal.py` | **создать** — unit + регресс на новый сигнал | +| `tests/watchdog/test_tick_proc_blocking_integration.py` | **создать** — интеграция tick→dispatch | +| `docs/architecture/README.md`, `docs/deployment/LITE_SETUP.md` | изменить — описать сигнал/ключи (NFR-5) | +| `docker-compose.yml` (сервис `orchestrator-watchdog`) | **условно** изменить — привилегия/mount (`pid: host` или `/proc:ro`) **только если** ADR выберет watchdog-side host-скан | +| `src/metrics.py` (`_build_*`, аддитивный раздел) | **условно** изменить — **только если** ADR выберет orch-side обогащение `/metrics`; **аддитивно**, без бампа `schema_version` | + +## 3. Функциональные требования + +### FR-1 — Новый сигнал `proc_blocking` (чистый builder) +В `watchdog/signals.py` добавить чистую функцию-builder (без I/O), которая по списку записей о +процессах-кандидатах и конфигу возвращает `Signal`-объекты: +- **Ключ** — per-entity, со **стабильной идентичностью** процесса (напр. `("proc_blocking", pid)` или + хеш `cmdline`), чтобы `AlertState`/cooldown работали по каждому процессу отдельно (как + `("container_down", name)`). +- **active=True** ⇔ возраст процесса `> cfg.proc_age_s` **И** командная строка матчит класс + «тест/дочерний» (паттерн pytest и родственные, конфигурируемо) **И** процесс **не** принадлежит + активному отслеживаемому джобу (анти-false-positive, BR-4). +- **title/detail** — действенные: фрагмент cmdline, PID, возраст (сек), доля/время CPU при наличии + (BR-2). Текст на русском, в стиле существующих сигналов. +- Привязка: **BR-1, BR-2, BR-4**. + +### FR-2 — Коллектор процессов-кандидатов +Создать `watchdog/collectors/proc.py` — собирает «сырьё» (список записей `{pid, cmdline, age_s, +cpu?}`) тем механизмом, который утвердит ADR. Контракт коллектора **фиксирован независимо от +механизма**: **stdlib-only** (NFR-6), **read-only** (NFR-2), **never-raise** → при любой ошибке/ +недоступности источника возвращает `[]` (один сигнал пропущен, тик жив). Привязка: **NFR-1, NFR-2, +NFR-6**. + +### FR-3 — Конфиг + kill-switch +В `watchdog/config.py` добавить ключи (имена финализирует разработчик/ADR; предложение): +`WATCHDOG_PROC_ENABLED` (kill-switch), `WATCHDOG_PROC_AGE_MIN` (порог возраста в минутах; дефолт +**должен превышать** макс. легитимный бюджет тест-прогона — см. §7), `WATCHDOG_PROC_PATTERNS` +(CSV паттернов cmdline, дефолт включает `pytest`), при необходимости отдельный +`WATCHDOG_PROC_COOLDOWN_S`. Все парсеры never-raise с безопасными дефолтами (как существующие +`_int`/`_bool`/`_csv`). Выключенный флаг → коллектор/сигнал инертны (нулевой эффект). Привязка: +**BR-6, NFR-1**. + +### FR-4 — Инвариант «только наблюдение» +На всём новом пути запрещены `os.kill`, отправка сигналов, `subprocess.Popen`/`run`, любые мутации +процессов/ФС/БД. Watchdog **только читает и уведомляет**. Привязка: **BR-3, NFR-2**. + +### FR-5 — Диспетч через существующую машину решения +Новый сигнал диспетчеризуется в `core.tick()` через тот же путь `decision.decide(...)` + +`self._states[key]` + `self._send(...)`: ALERT на пересечении порога, REALERT по cooldown, RECOVERY +при исчезновении процесса. Никакой отдельной логики анти-спама не вводить. Привязка: **BR-5**. + +### FR-6 — Без дубля с `agent_hung` +Новый сигнал покрывает **только** процессы, **не** представленные в `/metrics agents[]` (нетреканые/ +осиротевшие). Атрибуция «процесс принадлежит активному джобу» исключает такие процессы из +`proc_blocking` (предотвращает двойной алерт и ложные срабатывания на живом агенте). Привязка: +**NFR-4, BR-4**. + +## 4. Изменения API +- **Орк HTTP API:** новых эндпоинтов **не требуется**. **Условно** (если ADR выберет orch-side путь): + **аддитивный** раздел в ответе `GET /metrics` (`src/metrics.py`) о бесхозных тест-субпроцессах — + строго read-only, **без бампа** `schema_version` (ORCH-099 NFR-6), sidecar толерирует отсутствие. +- **Watchdog:** внутренний сигнал, внешнего API не имеет. + +## 5. Изменения схемы БД +Нет. + +## 6. Требования к новым/изменённым QG checks +Нет. Watchdog — наблюдатель **вне** конвейера и вне Quality Gates: `QG_CHECKS` / `check_*` / +machine-verdict ключи / `STAGE_TRANSITIONS` — **байт-в-байт не трогаются** (как `disk_watchdog`/ +`reaper`/`reconciler`). + +## 7. Совместимость / регресс +- **Kill-switch + дефолты:** при выключенном `WATCHDOG_PROC_ENABLED` (или при дефолте, выбранном + безопасно) — **нулевая регрессия**: ни одного нового алерта, тик 1:1 как до ORCH-111. Дефолтный + порог возраста **обязан превышать** максимальный легитимный бюджет тест-прогона + (`merge_retest_timeout_s` ≈ 600s, `coverage_run_timeout_s`) — иначе нормальный прогон даст ложный + alert (анти-false-positive, BR-4). +- **never-raise / read-only:** новый код не может уронить тик и не выполняет управляющих действий + (NFR-1/NFR-2). +- **Контракт `/metrics`:** при orch-side варианте — только аддитивно, без бампа версии; при + watchdog-side варианте — `/metrics` не трогается вовсе. +- **Self-hosting (NFR-3):** выкат — пересборка/рестарт **только** `orchestrator-watchdog`; прод + `orchestrator` **не** перезапускается. Если механизм требует привилегии/mount в compose — это правка + **только** сервиса watchdog. +- **Канон тиража (NFR-5):** новые ключи синхронизировать в `.env.watchdog.example` ↔ блок `WATCHDOG_*` + в `.env.example` и описать в `LITE_SETUP.md` в том же PR (key-set-sync тест должен остаться зелёным). +- **Область:** сигнал config-gated; enduro-trails не затронут. diff --git a/docs/work-items/ORCH-111/03-acceptance-criteria.md b/docs/work-items/ORCH-111/03-acceptance-criteria.md new file mode 100644 index 0000000..2b49659 --- /dev/null +++ b/docs/work-items/ORCH-111/03-acceptance-criteria.md @@ -0,0 +1,129 @@ +--- +work_item: ORCH-111 +stage: analysis +author_agent: analyst +status: ready-for-review +created_at: 2026-06-15 +model_used: claude-opus-4-8 +--- + +# 03 — Критерии приёмки (Acceptance Criteria): ORCH-111 — alert на долго живущие pytest/дочерние процессы + +Work Item: **ORCH-111** · Repo: **orchestrator** · Стадия: analysis + +Формат: каждый критерий имеет **PASS** (что должно быть истинно для приёмки) и **FAIL** (что считается +провалом). Reviewer/tester проверяет их буквально по файлам репозитория и тестам. + +--- + +## AC-1 — Алерт на долго живущий блокирующий процесс + +**Условие:** на хосте есть процесс тест-класса (pytest/субпроцесс гейта), чей возраст превысил +настроенный порог и который не атрибутирован активному отслеживаемому джобу. +- **PASS:** watchdog поднимает **отдельный** сигнал (рабочий ключ `proc_blocking`) → ALERT в свой + Telegram-канал; алерт срабатывает **даже если** ни одна стадия задачи формально не `stuck`. +- **FAIL:** такой процесс присутствует, но ни одного нового алерта не поднято (поведение до ORCH-111). + +--- + +## AC-2 — Действенный текст алерта + +**Условие:** алерт `proc_blocking` сформирован. +- **PASS:** `detail` содержит фрагмент cmdline (видно, что это pytest/тест-процесс), PID, возраст + процесса в секундах и (при наличии) долю/время CPU. +- **FAIL:** алерт без идентификации процесса (нельзя понять, какой PID/команду проверять). + +--- + +## AC-3 — Только наблюдение, без ремедиации + +**Условие:** обнаружен блокирующий процесс. +- **PASS:** на всём новом пути нет `os.kill`/отправки сигналов/`subprocess.Popen|run`/иных мутаций; + watchdog **только** алертит, процесс остаётся жив (его судьба — на операторе). +- **FAIL:** код пытается убить/остановить/просигналить процесс или иначе ремедиировать. + +--- + +## AC-4 — Без ложных срабатываний на легитимном прогоне + +**Условие:** на хосте идёт нормальный тест-прогон (merge-gate re-test / coverage) под активным +отслеживаемым джобом, в пределах своего бюджета. +- **PASS:** `proc_blocking` **не** активен для такого процесса (возраст ниже порога **или** процесс + атрибутирован активному джобу) → алерта нет. +- **FAIL:** легитимный прогон под активным джобом триггерит ложный `proc_blocking`-alert. + +--- + +## AC-5 — Без дубля с `agent_hung` + +**Условие:** процесс уже представлен в `/metrics agents[]` (отслеживаемый running-агент). +- **PASS:** для него работает только существующий `agent_hung`; `proc_blocking` его **не** дублирует + (ровно один класс алерта на один процесс). +- **FAIL:** один и тот же процесс порождает и `agent_hung`, и `proc_blocking` (двойной алерт). + +--- + +## AC-6 — Анти-спам и recovery + +**Условие:** блокирующий процесс держится несколько тиков, затем исчезает. +- **PASS:** один ALERT при пересечении порога; в пределах cooldown — `NONE` (не спамит); REALERT + по истечении cooldown; **однократный** RECOVERY при исчезновении процесса (через + `decision.decide`/`AlertState`). +- **FAIL:** алерт на каждом тике (спам) либо отсутствует recovery. + +--- + +## AC-7 — Kill-switch и нулевая регрессия + +**Условие:** `WATCHDOG_PROC_ENABLED` выключен (или дефолт-off). +- **PASS:** коллектор/сигнал инертны; набор и поведение тика **байт-в-байт** как до ORCH-111; полный + `pytest tests/` зелёный; watchdog-тесты зелёные. +- **FAIL:** при выключенном флаге появляется новый алерт/сетевой оверхед, либо падают существующие + тесты/тик. + +--- + +## AC-8 — never-raise / read-only + +**Условие:** коллектор процессов получает битый/пустой/недоступный источник. +- **PASS:** коллектор деградирует в `[]` (один сигнал пропущен), тик завершается штатно; ни одной + записи/мутации/управляющего действия над процессами/ФС/БД; sidecar остаётся stdlib-only. +- **FAIL:** исключение роняет тик, либо появляется новая сторонняя зависимость, либо мутирующая + операция. + +--- + +## AC-9 — Конвейер и контракты не тронуты + +**Условие:** изменение влито. +- **PASS:** `STAGE_TRANSITIONS` / `QG_CHECKS` / `check_*` / machine-verdict ключи / схема БД — + без изменений; правка `/metrics` (если выбрана) — **аддитивна**, `schema_version` не бампнут; + выкат не требует рестарта прод-контейнера `orchestrator`. +- **FAIL:** затронут любой из перечисленных контрактов или бампнут `schema_version` без ломающего + изменения, либо выкат требует рестарта прод-орка. + +--- + +## AC-10 — Канон конфига/тиража синхронизирован + +**Условие:** добавлены ключи `WATCHDOG_PROC_*` / изменён compose. +- **PASS:** ключи присутствуют и согласованы в `.env.watchdog.example` ↔ блоке `WATCHDOG_*` + `.env.example`; описаны в `LITE_SETUP.md` и `docs/architecture/README.md` в том же PR; key-set-sync + тест зелёный. +- **FAIL:** ключи добавлены только в код, канон/доки тиража не обновлены (дрейф). + +--- + +## Сводная матрица AC ↔ FR/BR +| AC | Покрывает | +|----|-----------| +| AC-1 | BR-1 / FR-1, FR-2, FR-5 | +| AC-2 | BR-2 / FR-1 | +| AC-3 | BR-3 / FR-4 / NFR-2 | +| AC-4 | BR-4 / FR-1, FR-6 | +| AC-5 | NFR-4 / FR-6 | +| AC-6 | BR-5 / FR-5 | +| AC-7 | BR-6 / FR-3 / §7 | +| AC-8 | NFR-1, NFR-2, NFR-6 / FR-2 | +| AC-9 | §6 / NFR-3 / §4 | +| AC-10 | NFR-5 / §7 | diff --git a/docs/work-items/ORCH-111/04-test-plan.yaml b/docs/work-items/ORCH-111/04-test-plan.yaml new file mode 100644 index 0000000..1c4ef20 --- /dev/null +++ b/docs/work-items/ORCH-111/04-test-plan.yaml @@ -0,0 +1,102 @@ +work_item: ORCH-111 +stage: analysis +author_agent: analyst +status: ready-for-review +created_at: 2026-06-15 +model_used: claude-opus-4-8 +title: "Watchdog alert на долго живущие pytest/дочерние процессы, блокирующие конвейер" +framework: pytest +scope: > + Покрывает новый сигнал watchdog `proc_blocking`: чистый builder, конфиг/kill-switch, + never-raise/read-only коллектор, анти-спам/recovery, отсутствие дубля с agent_hung и + диспетч в tick(). Вне покрытия: автоматический reap/kill процессов (ремедиация — вне + объёма задачи) и любые изменения конвейера/Quality Gates. +notes: > + Эталон тестируемости — чистые функции `watchdog/signals.py` + `watchdog/decision.py` + (тесты без контейнера/сокета/таймера, см. tests/watchdog/test_decision.py, + test_host_collector.py). TC-01 и TC-07 — ОБЯЗАТЕЛЬНЫЙ регресс-тест бага: красный до + фикса (сигнал/диспетч отсутствуют → нет алерта на долго живущий процесс), зелёный + после. Точная форма теста КОЛЛЕКТОРА (host-scan vs orch-/metrics enrichment) зависит + от механизма, утверждаемого архитектором (ADR); детерминированные TC ниже якорятся на + чистом сигнале + decision-поверхности и не зависят от выбора механизма. Полный регресс + `pytest tests/` обязан оставаться зелёным. + +tests: + - id: TC-01 + type: unit + description: > + РЕГРЕСС (красный→зелёный): builder сигнала по записи о процессе с возрастом > + порога и cmdline класса pytest, не принадлежащему активному джобу, возвращает + активный Signal с ключом `proc_blocking`. До фикса — алерт отсутствует. + module: tests/watchdog/test_proc_blocking_signal.py + expected: PASS + + - id: TC-02 + type: unit + description: > + Анти-false-positive: процесс с возрастом НИЖЕ порога ИЛИ атрибутированный активному + отслеживаемому джобу → сигнал НЕ активен (нет алерта). Покрывает BR-4. + module: tests/watchdog/test_proc_blocking_signal.py + expected: PASS + + - id: TC-03 + type: unit + description: > + Конфиг/kill-switch: `WATCHDOG_PROC_*` парсятся с безопасными дефолтами; дефолтный + порог возраста превышает merge_retest_timeout_s; выключенный `WATCHDOG_PROC_ENABLED` + делает коллектор/сигнал инертными (нулевая регрессия). Покрывает BR-6/FR-3/AC-7. + module: tests/watchdog/test_proc_blocking_signal.py + expected: PASS + + - id: TC-04 + type: unit + description: > + never-raise/read-only коллектора: битый/пустой/недоступный источник → `[]` (один + сигнал пропущен), без исключения; на пути нет os.kill/signal/subprocess/мутаций. + Покрывает NFR-1/NFR-2/AC-3/AC-8. + module: tests/watchdog/test_proc_blocking_signal.py + expected: PASS + + - id: TC-05 + type: unit + description: > + Анти-спам/recovery через decision.decide+AlertState: ALERT при пересечении порога, + NONE в пределах cooldown, REALERT по истечении, однократный RECOVERY при исчезновении + процесса. Покрывает BR-5/AC-6. + module: tests/watchdog/test_proc_blocking_signal.py + expected: PASS + + - id: TC-06 + type: unit + description: > + Без дубля с agent_hung: процесс, присутствующий в /metrics agents[], не порождает + `proc_blocking` (исключён атрибуцией к активному джобу). Покрывает NFR-4/FR-6/AC-5. + module: tests/watchdog/test_proc_blocking_signal.py + expected: PASS + + - id: TC-07 + type: integration + description: > + РЕГРЕСС tick→dispatch: Watchdog.tick() с инъектированным коллектором, отдающим долго + живущий блокирующий процесс, диспетчеризует `proc_blocking`-алерт через fake-Notifier; + при выключенном флаге алерт не отправляется. Покрывает BR-1/FR-5/AC-1/AC-7. + module: tests/watchdog/test_tick_proc_blocking_integration.py + expected: PASS + + - id: TC-08 + type: integration + description: > + Конфиг-канон/тираж: key-set `.env.watchdog.example` ↔ блок WATCHDOG_* в `.env.example` + синхронизирован после добавления `WATCHDOG_PROC_*` (key-sync/LITE_SETUP тест зелёный). + Покрывает NFR-5/AC-10. + module: tests/watchdog/test_config_killswitch.py + expected: PASS + + - id: TC-09 + type: integration + description: > + Полный регресс: `pytest tests/` зелёный; конвейер/Quality Gates не затронуты + (STAGE_TRANSITIONS/QG_CHECKS/схема БД без изменений); при orch-side варианте + schema_version /metrics не бампнут. Покрывает AC-9. + module: tests/ + expected: PASS From 7298f11064c4a2e5d5ee772494e2a9ae4847124f Mon Sep 17 00:00:00 2001 From: claude-bot Date: Mon, 15 Jun 2026 01:28:17 +0300 Subject: [PATCH 03/14] 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, без новых зависимостей). + From 2e73ccf09075efbdac4dcfaa8aaf4f0f9c3a78a1 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Mon, 15 Jun 2026 01:46:09 +0300 Subject: [PATCH 04/14] feat(watchdog): proc_blocking alert for orphaned long-lived test processes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close the observability gap between agent_hung (only tracked jobs by jobs.pid) and orphaned pytest subprocesses the orchestrator launches itself (merge_gate.retest_branch / coverage_gate.measure_coverage). On a timeout-kill of the agent (-9, ORCH-109) the grand-child pytest reparents onto tini and keeps running for days, starving CPU and failing merge-gate re-test — with no alert. Strictly inside the observer (watchdog/** + the watchdog compose service): - watchdog/collectors/proc.py: stdlib-only /proc scan (under pid: host), read-only, never-raise -> []; pure parsers split from I/O (tested on a fake /proc tree). Never reads /proc//environ. - watchdog/signals.py: pure proc_signals builder, per-entity ("proc_blocking", pid), active iff age_s > proc_age_s; actionable RU detail. - watchdog/core.py: opt-in tick block (gated on proc_enabled -> zero overhead / byte-for-byte when off) + RECOVERY synthesis for a vanished process through the existing decide()/AlertState (no new anti-spam logic). - watchdog/config.py: WATCHDOG_PROC_{ENABLED(false),AGE_MIN(60),PATTERNS(pytest), COOLDOWN_S(1800)}; default threshold > max(merge_retest_timeout_s=600, coverage_run_timeout_s=900) so a legit in-flight run never crosses it. - docker-compose.yml: pid: host on orchestrator-watchdog ONLY (read-only privilege). Anti-false-positive and no overlap with agent_hung are by construction (cmdline scope + age threshold), not fragile cross-namespace PID matching. Canon synced: WATCHDOG_PROC_* in .env.watchdog.example <-> .env.example block; documented in LITE_SETUP.md and docs/architecture/README.md (architect). src/**, /metrics, schema_version, STAGE_TRANSITIONS, QG_CHECKS, check_*, machine-verdict and the DB schema are untouched; deploy rebuilds only the sidecar, prod orchestrator is not restarted (NFR-3). Tests: tests/watchdog/test_proc_blocking_signal.py (TC-01..TC-06), test_proc_collector.py (/proc parsing), test_tick_proc_blocking_integration.py (TC-07), plus pid: host and proc-config assertions. Full pytest tests/ green (1930). Refs: ORCH-111 Co-Authored-By: Claude Opus 4.8 --- .env.example | 10 + .env.watchdog.example | 10 + .task-dev.md | 4 +- CHANGELOG.md | 6 + docker-compose.yml | 6 + docs/deployment/LITE_SETUP.md | 12 + tests/watchdog/test_compose_service.py | 11 + tests/watchdog/test_config_killswitch.py | 32 +++ tests/watchdog/test_proc_blocking_signal.py | 256 ++++++++++++++++++ tests/watchdog/test_proc_collector.py | 148 ++++++++++ .../test_tick_proc_blocking_integration.py | 128 +++++++++ watchdog/collectors/proc.py | 205 ++++++++++++++ watchdog/config.py | 18 ++ watchdog/core.py | 56 ++++ watchdog/signals.py | 48 ++++ 15 files changed, 948 insertions(+), 2 deletions(-) create mode 100644 tests/watchdog/test_proc_blocking_signal.py create mode 100644 tests/watchdog/test_proc_collector.py create mode 100644 tests/watchdog/test_tick_proc_blocking_integration.py create mode 100644 watchdog/collectors/proc.py diff --git a/.env.example b/.env.example index d5d4305..4920ee6 100644 --- a/.env.example +++ b/.env.example @@ -569,6 +569,12 @@ ORCH_QG0_TITLE_MAX=200 # CONTAINERS -> CSV of container names to watch (status != running/healthy). # DOCKER_SOCK -> path to the read-only docker.sock inside the container. # DEPS -> CSV of name=url dependency pings (empty -> no pings). +# PROC_ENABLED -> ORCH-111 opt-in: alert on a long-lived test process (pytest) +# orphaned on the host (needs `pid: host`, default OFF). +# PROC_AGE_MIN -> minutes a test process may live before alerting; MUST exceed +# max(merge_retest_timeout_s, coverage_run_timeout_s)/60. +# PROC_PATTERNS -> CSV of cmdline substrings that mark the test-class (pytest). +# PROC_COOLDOWN_S-> per-signal re-alert throttle for proc_blocking. # TG_BOT_TOKEN / TG_CHAT_ID -> the sidecar's OWN Telegram bot/chat (independent # of the orchestrator's; absent -> logs, does not send). WATCHDOG_ENABLED=true @@ -588,5 +594,9 @@ WATCHDOG_QUEUE_DEPTH=20 WATCHDOG_CONTAINERS=orchestrator WATCHDOG_DOCKER_SOCK=/var/run/docker.sock WATCHDOG_DEPS= +WATCHDOG_PROC_ENABLED=false +WATCHDOG_PROC_AGE_MIN=60 +WATCHDOG_PROC_PATTERNS=pytest +WATCHDOG_PROC_COOLDOWN_S=1800 WATCHDOG_TG_BOT_TOKEN= WATCHDOG_TG_CHAT_ID= diff --git a/.env.watchdog.example b/.env.watchdog.example index 08fcfe0..0b2d9ea 100644 --- a/.env.watchdog.example +++ b/.env.watchdog.example @@ -38,5 +38,15 @@ WATCHDOG_QUEUE_DEPTH=20 WATCHDOG_CONTAINERS=orchestrator WATCHDOG_DOCKER_SOCK=/var/run/docker.sock WATCHDOG_DEPS= +# proc_blocking (ORCH-111): opt-in алерт на долго живущий осиротевший тест-процесс +# (pytest), репарентированный на хост. Требует `pid: host` на сервисе +# orchestrator-watchdog (compose) — привилегия только у наблюдателя, read-only. +# Дефолт-off → нулевая регрессия. PROC_AGE_MIN ОБЯЗАН превышать +# max(merge_retest_timeout_s=600, coverage_run_timeout_s=900)/60 = 15 мин, иначе +# легитимный прогон даст ложный алерт. 60 мин = 4× запас. +WATCHDOG_PROC_ENABLED=false +WATCHDOG_PROC_AGE_MIN=60 +WATCHDOG_PROC_PATTERNS=pytest +WATCHDOG_PROC_COOLDOWN_S=1800 WATCHDOG_TG_BOT_TOKEN= WATCHDOG_TG_CHAT_ID= diff --git a/.task-dev.md b/.task-dev.md index ed13550..56db51d 100644 --- a/.task-dev.md +++ b/.task-dev.md @@ -1,4 +1,4 @@ -Work item: ORCH-011 +Work item: ORCH-111 Repo: orchestrator -Branch: feature/ORCH-011- +Branch: feature/ORCH-111-bug-watchdog-must-alert-on-lon Stage: development \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 45529da..f440101 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ Формат: [Keep a Changelog](https://keepachangelog.com/). Записи — на смысловой PR/задачу. ## [Unreleased] +- **Watchdog-сигнал `proc_blocking`: алерт на долго живущий осиротевший тест-процесс** (ORCH-111, `feat`): закрыта слепая зона наблюдаемости между `agent_hung` (видит только треканые джобы по `jobs.pid`) и осиротевшими субпроцессами `pytest`, которые орк запускает сам (`merge_gate.retest_branch`/`coverage_gate.measure_coverage`) и которые при timeout-kill агента (`-9`, ORCH-109) репарентируются на tini и живут сутками, грузя CPU и валя merge-gate re-test (инцидент: процессы `test_install_lite_script.py` жили >2 суток без единого алерта). Изменения **строго внутри наблюдателя** (`watchdog/**` + сервис watchdog в compose); `src/**`/`/metrics`/`schema_version`/`STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/machine-verdict/схема БД — **байт-в-байт не тронуты**; выкат пересобирает **только** `orchestrator-watchdog`, прод `orchestrator` не рестартится (NFR-3). ADR: `docs/work-items/ORCH-111/06-adr/ADR-001-watchdog-orphan-test-process-alert.md`, сквозной `docs/architecture/adr/adr-0041-watchdog-orphan-test-process-alert.md`. + - **Коллектор `watchdog/collectors/proc.py` (D3):** новый stdlib-only `/proc`-скан (под `pid: host` контейнерный `/proc` отражает хост-namespace) — читает `/proc/stat` (`btime`) + `os.sysconf("SC_CLK_TCK")`, итерирует числовые `/proc/`, матчит `/proc//cmdline` по паттерну тест-класса, парсит `/proc//stat` (поле 22 `starttime` → `age_s`, поля 14+15 `utime+stime` → `cpu_s` информационно). Строго **read-only** (никаких `os.kill`/сигналов/`subprocess`; **никогда** не читает `/proc//environ` — секреты); **never-raise** (per-pid гонка «процесс умер между listdir и read» пропускается, top-level → `[]`); чистый разбор отделён от I/O (тестируется на фейковом `/proc`-дереве). + - **Чистый builder `proc_signals` + синтез RECOVERY (D4):** per-entity `Signal("proc_blocking", pid)` active ⇔ `age_s > cfg.proc_age_s` (cmdline уже отфильтрована коллектором); действенный RU-`detail` (PID + возраст + усечённый фрагмент cmdline + CPU-время). Исчезновение процесса не оставляет «висящего» алерта: в `core.tick()` для каждого alerting-ключа без свежего сигнала **синтезируется** `Signal(active=False)` → существующая `decision.decide()`/`AlertState` даёт **однократный** RECOVERY и чистит состояние (никакой новой анти-спам-логики — FR-5). + - **Анти-false-positive и отсутствие дубля с `agent_hung` — по построению (D2):** cmdline-скоуп (`claude`-агент ≠ `pytest` → нулевое пересечение, NFR-4/AC-5) + дефолтный порог возраста (60 мин) **превышает** макс. легитимный бюджет тест-прогона `max(merge_retest_timeout_s=600, coverage_run_timeout_s=900)` → in-flight прогон физически не перерастает порог (BR-4/AC-4). Без хрупкого кросс-namespace матчинга PID. + - **Конфиг + kill-switch (D5):** ключи `WATCHDOG_PROC_ENABLED` (дефолт **false** — opt-in) / `WATCHDOG_PROC_AGE_MIN` (60) / `WATCHDOG_PROC_PATTERNS` (`pytest`) / `WATCHDOG_PROC_COOLDOWN_S` (1800), never-raise парсеры. При выключенном флаге коллектор в `tick()` **не вызывается** → нулевой оверхед и байт-в-байт прежний тик (AC-7). Топология (D6): аддитивный `pid: host` **только** на сервисе `orchestrator-watchdog` (привилегия read-only, меньше уже-смонтированного `docker.sock`; не volume → инвариант read-only-маунтов цел). + - **Канон тиража (NFR-5):** новые `WATCHDOG_PROC_*` синхронизированы в `.env.watchdog.example` ↔ блок `WATCHDOG_*` `.env.example` (key-sync тест зелёный), описаны в `docs/deployment/LITE_SETUP.md` §4 и `docs/architecture/README.md` (§ proc_blocking). Покрытие — `tests/watchdog/test_proc_blocking_signal.py` (TC-01…TC-06), `test_proc_collector.py` (парсинг `/proc`), `test_tick_proc_blocking_integration.py` (TC-07 tick→dispatch + flag-off), позитивный `pid: host` в `test_compose_service.py`, proc-конфиг в `test_config_killswitch.py`. Полный `pytest tests/` зелёный (1930). - **Timeout-бюджеты developer/reviewer + launch-стамп модели в телеметрии** (ORCH-109, `fix`): две аддитивные изолированные правки подсистемы запуска агентов (инцидент ORCH-104, runs 658/659/660), **без** касания `STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/machine-verdict/схемы БД. ADR: `docs/work-items/ORCH-109/06-adr/ADR-001-agent-timeout-budgets-and-launch-model-stamp.md`, сквозной `docs/architecture/adr/adr-0040-agent-timeout-budgets-and-launch-model-stamp.md`. - **Launch-стамп модели (D1, FR-1):** резолвенная `resolve_agent_model(...)` пишется в `agent_runs.model` в **момент launch** объединённым `UPDATE agent_runs SET model=?, effort=? WHERE id=?` рядом со стампом эффорта (ORCH-087) в `launcher._spawn`. Раньше модель писалась только постфактум из финального usage-JSON (`record_usage`, `model=COALESCE(?, model)`), а убитый по тайм-ауту прогон этот JSON не эмитит → модель оставалась `NULL` ровно тогда, когда нужна для разбора инцидента. Теперь модель присутствует с launch, **переживает timeout-kill (`exit_code=-9`)**, видна in-flight в `GET /metrics`/`GET /queue` (`get_running_agents` уже отдаёт `model`) и в строке Telegram-карточки. Пустой резолв (CLI-дефолт без `--model`) → `NULL` (симметрично `effort or None`). Постфактум `record_usage` остаётся **обогащением** (COALESCE сохраняет launch-стамп при `model=None`). never-raise: сбой стампа изолирован `try/except` + WARNING, launch продолжается. - **Поднятые per-role wall-clock бюджеты (D3/D4, FR-3):** выделенные типизированные ключи `agent_timeout_developer_s=3600` (60м) / `agent_timeout_reviewer_s=3000` (50м) (env `ORCH_AGENT_TIMEOUT_DEVELOPER_S`/`_REVIEWER_S`). `_resolve_timeout(agent)` получил детерминированную лестницу: `agent_timeout_overrides_json[agent]` (операторский escape-hatch, высший приоритет, BC) → выделенный ключ роли → `agent_timeout_seconds=1800` (прочие роли — байт-в-байт). Малформный JSON / непозитивный/нечисловой выделенный ключ → откат на глобальный дефолт + WARNING (never-break). Дефолты = боевым значениям (канон ORCH-101): пустой `.env` воспроизводит поднятые бюджеты. **Кросс-инвариант reaper ORCH-065** сохранён синхронным поднятием `reaper_max_running_s` 3600 → **5400** (`5400 > max(timeout)3600 + grace20 = 3620`). diff --git a/docker-compose.yml b/docker-compose.yml index 67f8ee9..1338403 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -79,6 +79,12 @@ services: restart: unless-stopped init: true network_mode: host + # ORCH-111 (adr-0041 D6): share the host PID-namespace so the sidecar's /proc + # reflects the host and the proc_blocking collector can see orphaned pytest + # subprocesses. Privilege is read-only and ONLY on the observer; the signal + # is default-off (WATCHDOG_PROC_ENABLED=false) -> no behaviour change unless + # opted in. NOT a volume, so the host-paths-read-only compose test is unaffected. + pid: host mem_limit: 128m mem_reservation: 32m volumes: diff --git a/docs/deployment/LITE_SETUP.md b/docs/deployment/LITE_SETUP.md index 50bc5b0..9265dc7 100644 --- a/docs/deployment/LITE_SETUP.md +++ b/docs/deployment/LITE_SETUP.md @@ -163,6 +163,18 @@ cp .env.watchdog.example .env.watchdog # заполнить два ключа: WATCHDOG_TG_BOT_TOKEN / WATCHDOG_TG_CHAT_ID (бота создадим в §8) ``` +**Опционально (ORCH-111): алерт на осиротевший тест-процесс.** Watchdog умеет +поднимать сигнал `proc_blocking` на долго живущий процесс тест-класса (по умолчанию +`pytest`), репарентированный на хост и грузящий CPU. По умолчанию **выключен** +(`WATCHDOG_PROC_ENABLED=false`) — нулевая регрессия. Чтобы включить, в `.env.watchdog`: +`WATCHDOG_PROC_ENABLED=true`, при необходимости подстройте `WATCHDOG_PROC_AGE_MIN` +(минуты; **обязан** превышать `max(merge_retest_timeout_s, coverage_run_timeout_s)/60`, +дефолт 60), `WATCHDOG_PROC_PATTERNS` (CSV cmdline-подстрок), `WATCHDOG_PROC_COOLDOWN_S`. +Для видимости процессов хоста сервису `orchestrator-watchdog` в `docker-compose.yml` +задан `pid: host` (привилегия только у наблюдателя, read-only). **Проверка:** +`grep -E '^WATCHDOG_PROC_ENABLED=' .env.watchdog` — если `true`, после рестарта только +sidecar (`docker compose up -d orchestrator-watchdog`) в его логах виден тик без ошибок. + **Проверка (резолв всей конфигурации):** ```bash diff --git a/tests/watchdog/test_compose_service.py b/tests/watchdog/test_compose_service.py index 359a950..71352c1 100644 --- a/tests/watchdog/test_compose_service.py +++ b/tests/watchdog/test_compose_service.py @@ -48,6 +48,17 @@ def test_host_paths_mounted_read_only(): assert v.endswith(":ro"), f"watchdog mount must be :ro: {v}" +def test_watchdog_shares_host_pid_namespace(): + # ORCH-111 (adr-0041 D6): the sidecar shares the host PID-namespace so its + # /proc reflects the host (proc_blocking collector). `pid: host` is NOT a + # volume, so the read-only-mounts invariant above is unaffected. + wd = _compose()["services"]["orchestrator-watchdog"] + assert wd.get("pid") == "host", "orchestrator-watchdog must declare `pid: host`" + # The privilege stays on the OBSERVER only — prod orchestrator must NOT get it. + orch = _compose()["services"]["orchestrator"] + assert "pid" not in orch, "the prod orchestrator service must not share the host PID-namespace" + + def test_env_file_is_optional(): # A missing .env.watchdog must not break `docker compose up` (self-hosting). wd = _compose()["services"]["orchestrator-watchdog"] diff --git a/tests/watchdog/test_config_killswitch.py b/tests/watchdog/test_config_killswitch.py index 001d14a..bf3a0de 100644 --- a/tests/watchdog/test_config_killswitch.py +++ b/tests/watchdog/test_config_killswitch.py @@ -67,3 +67,35 @@ def test_malformed_env_degrades_to_default(): cfg = Config.from_env({"WATCHDOG_INTERVAL_S": "abc", "WATCHDOG_MEM_PCT": ""}) assert cfg.interval_s == 30.0 assert cfg.mem_pct == 90.0 + + +# -- ORCH-111: proc_blocking config (kill-switch default-off + safe threshold) -- +def test_proc_blocking_defaults_off_and_safe(): + cfg = Config.from_env({}) + assert cfg.proc_enabled is False # opt-in (needs `pid: host`) + assert cfg.proc_patterns == ["pytest"] + assert cfg.proc_cooldown_s == 1800.0 + # Cross-invariant (adr-0041 D2): the default age threshold MUST exceed the max + # legitimate test-run budget max(merge_retest_timeout_s=600, coverage=900). + assert cfg.proc_age_s > 900.0 + + +def test_proc_blocking_thresholds_read_from_env(): + cfg = Config.from_env( + { + "WATCHDOG_PROC_ENABLED": "true", + "WATCHDOG_PROC_AGE_MIN": "45", + "WATCHDOG_PROC_PATTERNS": "pytest,coverage run", + "WATCHDOG_PROC_COOLDOWN_S": "900", + } + ) + assert cfg.proc_enabled is True + assert cfg.proc_age_s == 45 * 60.0 + assert cfg.proc_patterns == ["pytest", "coverage run"] + assert cfg.proc_cooldown_s == 900.0 + + +def test_proc_blocking_malformed_env_degrades(): + cfg = Config.from_env({"WATCHDOG_PROC_AGE_MIN": "nope", "WATCHDOG_PROC_ENABLED": ""}) + assert cfg.proc_age_min == 60.0 + assert cfg.proc_enabled is False diff --git a/tests/watchdog/test_proc_blocking_signal.py b/tests/watchdog/test_proc_blocking_signal.py new file mode 100644 index 0000000..38e86d9 --- /dev/null +++ b/tests/watchdog/test_proc_blocking_signal.py @@ -0,0 +1,256 @@ +"""ORCH-111 TC-01…TC-06: the proc_blocking signal builder + decision surface. + +Pure / deterministic — no real ``/proc``, no container, no socket, no timer. The +collector is exercised here only for its never-raise / read-only contract +(TC-04); its ``/proc`` parsing fixtures live in ``test_proc_collector.py``. + +TC-01 is the REGRESS anchor: before ORCH-111 there was no ``proc_blocking`` +builder/dispatch at all, so a long-lived orphaned pytest raised no alert; this +asserts the active signal is now produced (red→green). +""" +import ast as _ast +import inspect as _inspect + +from watchdog.collectors import proc as proc_mod +from watchdog.config import Config +from watchdog.core import Watchdog +from watchdog.decision import ( + ACTION_ALERT, + ACTION_NONE, + ACTION_REALERT, + ACTION_RECOVERY, +) +from watchdog.signals import proc_signals + + +def _cfg(**kw) -> Config: + base = {"WATCHDOG_PROC_ENABLED": "true", "WATCHDOG_PROC_AGE_MIN": "60"} + return Config.from_env({**base, **kw}) + + +def _candidate(pid=4242, age_s=7200.0, cmdline="python3 -m pytest tests/", cpu_s=1234.0): + return {"pid": pid, "cmdline": cmdline, "age_s": age_s, "cpu_s": cpu_s, "start_ticks": 1} + + +# -- TC-01: regress — active signal for a long-lived blocking process --------- +def test_tc01_builder_emits_active_proc_blocking_signal(): + cfg = _cfg() # proc_age_s == 3600 + sigs = proc_signals(cfg, [_candidate(pid=4242, age_s=7200.0)]) + assert len(sigs) == 1 + sig = sigs[0] + assert sig.key == ("proc_blocking", 4242) + assert sig.active is True # 7200 > 3600 + # AC-2: actionable detail — PID, age in seconds, cmdline fragment, CPU time. + assert "4242" in sig.detail + assert "7200" in sig.detail + assert "pytest" in sig.detail + assert "CPU" in sig.detail + assert sig.cooldown_s == cfg.proc_cooldown_s + + +# -- TC-02: anti-false-positive — below the threshold -> inactive ------------- +def test_tc02_below_threshold_is_inactive(): + cfg = _cfg() # proc_age_s == 3600 + sigs = proc_signals(cfg, [_candidate(age_s=600.0)]) # within a 600s test budget + assert len(sigs) == 1 + assert sigs[0].active is False # 600 < 3600 -> no alert (BR-4 / AC-4) + + +def test_tc02_boundary_is_strict_greater_than(): + cfg = _cfg() + at_threshold = proc_signals(cfg, [_candidate(age_s=cfg.proc_age_s)]) + assert at_threshold[0].active is False # strict `>`: exactly at threshold is OK + over = proc_signals(cfg, [_candidate(age_s=cfg.proc_age_s + 1)]) + assert over[0].active is True + + +# -- TC-03: config / kill-switch + default threshold > test-run budget -------- +def test_tc03_defaults_are_off_and_safe(): + cfg = Config.from_env({}) + assert cfg.proc_enabled is False # default-OFF (opt-in, D5) + assert cfg.proc_patterns == ["pytest"] + assert cfg.proc_cooldown_s == 1800.0 + # Cross-invariant (D2): default age threshold MUST exceed the max legitimate + # test-run budget max(merge_retest_timeout_s=600, coverage_run_timeout_s=900). + assert cfg.proc_age_s > 900.0 + + +def test_tc03_env_overrides_and_malformed_degrade(): + cfg = Config.from_env( + { + "WATCHDOG_PROC_ENABLED": "true", + "WATCHDOG_PROC_AGE_MIN": "30", + "WATCHDOG_PROC_PATTERNS": "pytest,coverage run", + "WATCHDOG_PROC_COOLDOWN_S": "600", + } + ) + assert cfg.proc_enabled is True + assert cfg.proc_age_s == 30 * 60.0 + assert cfg.proc_patterns == ["pytest", "coverage run"] + assert cfg.proc_cooldown_s == 600.0 + # malformed numerics degrade to defaults (never-raise config). + bad = Config.from_env({"WATCHDOG_PROC_AGE_MIN": "abc", "WATCHDOG_PROC_COOLDOWN_S": ""}) + assert bad.proc_age_min == 60.0 + assert bad.proc_cooldown_s == 1800.0 + + +def test_tc03_killswitch_off_makes_collector_inert(): + cfg = Config.from_env({"WATCHDOG_PROC_ENABLED": "false"}) + dog = Watchdog(cfg, notifier=_Notifier(), docker=_StubDocker(), now_provider=lambda: 0.0) + # The gated collector returns [] without ever touching /proc (zero overhead). + assert dog._collect_proc(now=0.0) == [] + + +# -- TC-04: collector never-raise / read-only --------------------------------- +def test_tc04_collector_degrades_to_empty_on_broken_source(): + # Missing /proc root -> [] (one signal skipped), no exception. + assert proc_mod.collect_candidates(["pytest"], now=0.0, proc_root="/no/such/proc") == [] + + +def test_tc04_collector_empty_when_btime_unreadable(tmp_path): + # /proc with no parseable btime -> [] (cannot compute age -> no bogus signal). + (tmp_path / "stat").write_text("cpu 1 2 3\nintr 0\n") + assert proc_mod.collect_candidates(["pytest"], now=0.0, proc_root=str(tmp_path)) == [] + + +def _docstring_node_ids(tree) -> set: + """ids of the Constant nodes that are module/func/class docstrings (prose).""" + out = set() + for node in _ast.walk(tree): + if isinstance(node, (_ast.Module, _ast.FunctionDef, _ast.AsyncFunctionDef, _ast.ClassDef)): + body = getattr(node, "body", []) + if ( + body + and isinstance(body[0], _ast.Expr) + and isinstance(body[0].value, _ast.Constant) + and isinstance(body[0].value.value, str) + ): + out.add(id(body[0].value)) + return out + + +def test_tc04_collector_source_is_read_only(): + # AC-3 / NFR-2: the EXECUTABLE code (not the prose describing the contract) + # carries no kill / signal / subprocess / environ-read. Scan the AST so the + # docstring that documents the ban does not trip the check. + tree = _ast.parse(_inspect.getsource(proc_mod)) + docstrings = _docstring_node_ids(tree) + violations: list[str] = [] + _MUTATING_ATTRS = {"kill", "system", "Popen", "popen", "run", "send_signal", "terminate"} + for node in _ast.walk(tree): + if isinstance(node, _ast.Import): + for a in node.names: + if a.name.split(".")[0] in {"subprocess", "signal"}: + violations.append(f"import {a.name}") + elif isinstance(node, _ast.ImportFrom): + if (node.module or "").split(".")[0] in {"subprocess", "signal"}: + violations.append(f"from {node.module}") + elif isinstance(node, _ast.Attribute) and node.attr in _MUTATING_ATTRS: + violations.append(f".{node.attr}") + elif isinstance(node, _ast.Constant) and isinstance(node.value, str): + if id(node) not in docstrings and "environ" in node.value: + violations.append("reads /proc//environ") + assert not violations, f"read-only contract violated in proc.py: {violations}" + + +def test_tc04_builder_skips_records_missing_fields(): + cfg = _cfg() + sigs = proc_signals(cfg, [{"pid": None}, {"cmdline": "pytest"}, _candidate()]) + assert [s.key for s in sigs] == [("proc_blocking", 4242)] # only the valid record + + +# -- TC-05: anti-spam / recovery through decide()/AlertState ------------------ +def test_tc05_alert_throttle_realert_then_recovery(): + seq = {"candidates": [_candidate(pid=7, age_s=7200.0)]} + cfg = _cfg(WATCHDOG_PROC_COOLDOWN_S="1000") + t = {"v": 0.0} + notifier = _Notifier() + dog = Watchdog(cfg, notifier=notifier, docker=_StubDocker(), now_provider=lambda: t["v"]) + dog._collect_proc = lambda now: list(seq["candidates"]) # inject collector + + def proc_alerts(): + return [m for m in notifier.sent if "Блокирующий процесс" in m] + + def actions(): + return [a for a, s in dog.tick() if getattr(s, "key", (None,))[0] == "proc_blocking"] + + # tick 1: threshold crossed -> exactly one ALERT. + assert ACTION_ALERT in actions() + assert len(proc_alerts()) == 1 + # tick 2: still alive, within cooldown -> NONE (anti-spam, no new alert). + t["v"] = 100.0 + assert actions() == [ACTION_NONE] + assert len(proc_alerts()) == 1 + # tick 3: cooldown elapsed -> REALERT. + t["v"] = 1100.0 + assert ACTION_REALERT in actions() + assert len(proc_alerts()) == 2 + # tick 4: the process vanished -> exactly one RECOVERY (synthesised, D4). + seq["candidates"] = [] + t["v"] = 1200.0 + assert ACTION_RECOVERY in actions() + recoveries = [m for m in notifier.sent if "восстановление" in m and "Блокирующий" in m] + assert len(recoveries) == 1 + # tick 5: still gone -> no repeated recovery (state cleared). + t["v"] = 1300.0 + dog.tick() + assert len([m for m in notifier.sent if "восстановление" in m and "Блокирующий" in m]) == 1 + + +# -- TC-06: no duplicate with agent_hung (cmdline partition) ------------------ +def test_tc06_claude_agent_cmdline_never_matches_pytest_pattern(): + # A claude agent process (covered by agent_hung) is excluded by the collector + # pattern scope -> proc_blocking never fires for it (NFR-4 / AC-5, by construction). + assert proc_mod.matches_patterns("claude --model claude-opus-4-8 -p ...", ["pytest"]) is False + assert proc_mod.matches_patterns("python3 -m pytest tests/", ["pytest"]) is True + + +def test_tc06_collector_excludes_non_matching_processes(tmp_path): + _write_fake_proc( + tmp_path, + btime=1_000_000, + procs={ + "100": ("claude --model claude-opus-4-8", _stat_line(start_ticks=0)), + "200": ("python3 -m pytest tests/test_x.py", _stat_line(start_ticks=0)), + }, + ) + recs = proc_mod.collect_candidates( + ["pytest"], now=1_010_000.0, proc_root=str(tmp_path), clk_tck=100 + ) + assert [r["pid"] for r in recs] == [200] # only the pytest process + + +# -- shared fakes ------------------------------------------------------------- +class _Notifier: + def __init__(self): + self.sent = [] + + def send(self, text): + self.sent.append(text) + return True + + +class _StubDocker: + def inspect(self, name): + return {"State": {"Status": "running"}} + + +def _stat_line(start_ticks: int, utime: int = 0, stime: int = 0) -> str: + # /proc//stat: pid (comm) state ppid ... utime(14) stime(15) ... starttime(22) ... + fields = ["0"] * 52 + fields[0] = "999" + fields[1] = "(python3)" + fields[2] = "S" + fields[13] = str(utime) # field 14 + fields[14] = str(stime) # field 15 + fields[21] = str(start_ticks) # field 22 + return " ".join(fields) + + +def _write_fake_proc(root, *, btime: int, procs: dict): + (root / "stat").write_text(f"cpu 1 2 3\nbtime {btime}\nintr 0\n") + for pid, (cmdline, stat_line) in procs.items(): + d = root / pid + d.mkdir() + (d / "cmdline").write_bytes(cmdline.replace(" ", "\x00").encode() + b"\x00") + (d / "stat").write_text(stat_line) diff --git a/tests/watchdog/test_proc_collector.py b/tests/watchdog/test_proc_collector.py new file mode 100644 index 0000000..aa6010c --- /dev/null +++ b/tests/watchdog/test_proc_collector.py @@ -0,0 +1,148 @@ +"""ORCH-111: the /proc collector — pure parsing + a fake /proc tree (never-raise). + +Mirrors ``test_host_collector.py``: the pure parsers are unit-tested on text +fixtures and ``collect_candidates`` is driven against a temporary ``/proc`` tree, +so no real host / Linux kernel is required. +""" +from watchdog.collectors import proc as proc_mod + + +# -- parse_btime -------------------------------------------------------------- +def test_parse_btime_reads_the_btime_line(): + text = "cpu 1 2 3 4\nbtime 1700000000\nprocesses 99\n" + assert proc_mod.parse_btime(text) == 1700000000 + + +def test_parse_btime_absent_is_none(): + assert proc_mod.parse_btime("cpu 1 2 3\nintr 0\n") is None + + +def test_parse_btime_garbage_is_none(): + assert proc_mod.parse_btime("btime not-a-number\n") is None + assert proc_mod.parse_btime("") is None + + +# -- parse_pid_stat (comm may contain spaces/parens) -------------------------- +def test_parse_pid_stat_simple(): + # field 14 utime, 15 stime, 22 starttime. + fields = ["0"] * 52 + fields[0], fields[1], fields[2] = "1234", "(python3)", "R" + fields[13], fields[14], fields[21] = "50", "25", "9000" + st = proc_mod.parse_pid_stat(" ".join(fields)) + assert st == {"utime": 50, "stime": 25, "starttime": 9000} + + +def test_parse_pid_stat_comm_with_spaces_and_parens(): + # A pathological comm "(py (test) x)" must not break field indexing — we + # split after the LAST ')'. + fields = ["0"] * 52 + fields[13], fields[14], fields[21] = "7", "3", "4242" + tail = " ".join(fields[2:]) + line = f"1234 (py (test) x) {tail}" + st = proc_mod.parse_pid_stat(line) + assert st == {"utime": 7, "stime": 3, "starttime": 4242} + + +def test_parse_pid_stat_truncated_is_none(): + assert proc_mod.parse_pid_stat("1234 (python3) R 1 2 3") is None + assert proc_mod.parse_pid_stat("no parens here") is None + assert proc_mod.parse_pid_stat("") is None + + +# -- decode_cmdline ----------------------------------------------------------- +def test_decode_cmdline_nul_separated(): + raw = b"python3\x00-m\x00pytest\x00tests/test_x.py\x00" + assert proc_mod.decode_cmdline(raw) == "python3 -m pytest tests/test_x.py" + + +def test_decode_cmdline_empty_for_kernel_thread(): + assert proc_mod.decode_cmdline(b"") == "" + assert proc_mod.decode_cmdline(None) == "" + + +# -- matches_patterns --------------------------------------------------------- +def test_matches_patterns_substring_any(): + assert proc_mod.matches_patterns("python3 -m pytest x", ["pytest"]) is True + assert proc_mod.matches_patterns("python3 -m coverage run", ["pytest", "coverage run"]) is True + assert proc_mod.matches_patterns("bash -c sleep", ["pytest"]) is False + assert proc_mod.matches_patterns("", ["pytest"]) is False + assert proc_mod.matches_patterns("pytest", []) is False + + +# -- collect_candidates (fake /proc tree) ------------------------------------- +def _stat_line(start_ticks, utime=0, stime=0): + fields = ["0"] * 52 + fields[0], fields[1], fields[2] = "999", "(python3)", "S" + fields[13], fields[14], fields[21] = str(utime), str(stime), str(start_ticks) + return " ".join(fields) + + +def _write_proc(root, btime, procs): + (root / "stat").write_text(f"cpu 1 2 3\nbtime {btime}\n") + for pid, (cmdline, stat) in procs.items(): + d = root / pid + d.mkdir() + (d / "cmdline").write_bytes(cmdline.replace(" ", "\x00").encode() + b"\x00") + (d / "stat").write_text(stat) + + +def test_collect_candidates_computes_age_and_cpu(tmp_path): + # btime=1_000_000, starttime=200_000 ticks @ 100 Hz -> start epoch = 1_002_000. + # now=1_010_000 -> age 8000s; utime+stime=300 ticks @100Hz -> cpu 3s. + _write_proc( + tmp_path, + btime=1_000_000, + procs={"200": ("python3 -m pytest tests/", _stat_line(200_000, utime=200, stime=100))}, + ) + recs = proc_mod.collect_candidates( + ["pytest"], now=1_010_000.0, proc_root=str(tmp_path), clk_tck=100 + ) + assert len(recs) == 1 + r = recs[0] + assert r["pid"] == 200 + assert r["cmdline"] == "python3 -m pytest tests/" + assert abs(r["age_s"] - 8000.0) < 1e-6 + assert abs(r["cpu_s"] - 3.0) < 1e-6 + + +def test_collect_candidates_filters_by_pattern(tmp_path): + _write_proc( + tmp_path, + btime=1_000_000, + procs={ + "100": ("claude --model x", _stat_line(0)), + "200": ("python3 -m pytest a", _stat_line(0)), + "300": ("/usr/bin/dockerd", _stat_line(0)), + }, + ) + recs = proc_mod.collect_candidates( + ["pytest"], now=1_010_000.0, proc_root=str(tmp_path), clk_tck=100 + ) + assert [r["pid"] for r in recs] == [200] + + +def test_collect_candidates_skips_unreadable_pid(tmp_path): + # A matching pid whose stat is unparseable (race: vanished mid-scan) is + # skipped without dropping the rest. + _write_proc( + tmp_path, + btime=1_000_000, + procs={ + "200": ("python3 -m pytest a", "garbage no parens"), + "201": ("python3 -m pytest b", _stat_line(0)), + }, + ) + recs = proc_mod.collect_candidates( + ["pytest"], now=1_010_000.0, proc_root=str(tmp_path), clk_tck=100 + ) + assert [r["pid"] for r in recs] == [201] + + +def test_collect_candidates_ignores_non_numeric_entries(tmp_path): + _write_proc(tmp_path, btime=1_000_000, procs={"200": ("pytest", _stat_line(0))}) + (tmp_path / "self").mkdir() # non-numeric -> ignored + (tmp_path / "meminfo").write_text("noise") + recs = proc_mod.collect_candidates( + ["pytest"], now=1_000_000.0, proc_root=str(tmp_path), clk_tck=100 + ) + assert [r["pid"] for r in recs] == [200] diff --git a/tests/watchdog/test_tick_proc_blocking_integration.py b/tests/watchdog/test_tick_proc_blocking_integration.py new file mode 100644 index 0000000..0aa0aa7 --- /dev/null +++ b/tests/watchdog/test_tick_proc_blocking_integration.py @@ -0,0 +1,128 @@ +"""ORCH-111 TC-07: full tick -> dispatch of the proc_blocking alert (integration). + +REGRESS: ``Watchdog.tick()`` with a collector that returns a long-lived blocking +process must dispatch exactly one ``proc_blocking`` alert through the fake +Notifier — even though ``/metrics`` reports no ``stuck`` stage and no hung agent. +With the kill-switch OFF the path is inert (byte-for-byte as before ORCH-111). + +The orchestrator ``/metrics`` envelope is stubbed healthy so ONLY the new signal +can fire; the proc collector is stubbed at the module boundary so the real +``_collect_proc`` gate + wrapper still execute. +""" +from watchdog.collectors import orch as orch_mod +from watchdog.collectors import proc as proc_mod +from watchdog.config import Config +from watchdog.core import Watchdog + + +class _Notifier: + def __init__(self): + self.sent = [] + + def send(self, text): + self.sent.append(text) + return True + + +class _StubDocker: + def inspect(self, name): + return {"State": {"Status": "running"}} + + +def _healthy_metrics(monkeypatch): + env = { + "schema_version": 1, + "generated_at": "2026-06-15T00:00:00Z", + "clk_tck": 100, + "agents": [], + "stages": [], + "queue": {"depth": 0, "counts": {"failed": 0}}, + } + monkeypatch.setattr( + orch_mod, "fetch_metrics", + lambda *a, **k: orch_mod.FetchResult(ok=True, envelope=env), + ) + + +def _cfg(**kw): + base = { + "WATCHDOG_TG_BOT_TOKEN": "t", + "WATCHDOG_TG_CHAT_ID": "c", + "WATCHDOG_PROC_ENABLED": "true", + "WATCHDOG_PROC_AGE_MIN": "60", # proc_age_s == 3600 + "WATCHDOG_CONTAINERS": "orchestrator", + } + return Config.from_env({**base, **kw}) + + +def _blocking(monkeypatch, age_s=7200.0): + rec = {"pid": 4242, "cmdline": "python3 -m pytest tests/test_install_lite_script.py", + "age_s": age_s, "cpu_s": 99999.0, "start_ticks": 1} + monkeypatch.setattr(proc_mod, "collect_candidates", lambda *a, **k: [rec]) + + +def _proc_alerts(notifier): + return [m for m in notifier.sent if "Блокирующий процесс" in m] + + +def test_tc07_tick_dispatches_proc_blocking_alert(monkeypatch): + _healthy_metrics(monkeypatch) + _blocking(monkeypatch) + notifier = _Notifier() + dog = Watchdog(_cfg(), notifier=notifier, docker=_StubDocker(), now_provider=lambda: 0.0) + + dog.tick() + + alerts = _proc_alerts(notifier) + assert len(alerts) == 1 + assert "4242" in alerts[0] + assert "pytest" in alerts[0] + assert alerts[0].startswith("\U0001f534") # red ALERT prefix + + +def test_tc07_killswitch_off_dispatches_nothing(monkeypatch): + _healthy_metrics(monkeypatch) + # Even if the collector WOULD return a blocking process, the gate skips it. + called = {"n": 0} + + def _boom(*a, **k): + called["n"] += 1 + return [{"pid": 1, "cmdline": "pytest", "age_s": 9e9, "cpu_s": 0.0}] + + monkeypatch.setattr(proc_mod, "collect_candidates", _boom) + notifier = _Notifier() + dog = Watchdog( + _cfg(WATCHDOG_PROC_ENABLED="false"), + notifier=notifier, docker=_StubDocker(), now_provider=lambda: 0.0, + ) + + dog.tick() + + assert _proc_alerts(notifier) == [] + assert called["n"] == 0 # collector never invoked when disabled (zero overhead) + + +def test_tc07_in_budget_process_does_not_alert(monkeypatch): + # A process below the threshold (legitimate in-flight run) -> no alert (AC-4). + _healthy_metrics(monkeypatch) + _blocking(monkeypatch, age_s=600.0) + notifier = _Notifier() + dog = Watchdog(_cfg(), notifier=notifier, docker=_StubDocker(), now_provider=lambda: 0.0) + + dog.tick() + + assert _proc_alerts(notifier) == [] + + +def test_tc07_tick_never_raises_when_collector_explodes(monkeypatch): + _healthy_metrics(monkeypatch) + + def _explode(*a, **k): + raise RuntimeError("boom") + + monkeypatch.setattr(proc_mod, "collect_candidates", _explode) + notifier = _Notifier() + dog = Watchdog(_cfg(), notifier=notifier, docker=_StubDocker(), now_provider=lambda: 0.0) + + dog.tick() # must not raise — collector error degrades to one skipped signal + assert _proc_alerts(notifier) == [] diff --git a/watchdog/collectors/proc.py b/watchdog/collectors/proc.py new file mode 100644 index 0000000..c6c1730 --- /dev/null +++ b/watchdog/collectors/proc.py @@ -0,0 +1,205 @@ +"""Collector: long-lived host processes whose cmdline matches a test-class (ORCH-111). + +stdlib-only ``/proc`` scan (ADR-001 D3). Under ``pid: host`` (D6) the container's +``/proc`` reflects the host PID-namespace, so the sidecar sees the orphaned +``pytest`` subprocess regardless of which container spawned it (the merge-gate / +coverage-gate re-test the orchestrator launches itself; on a timeout-kill of the +agent — ``exit_code=-9``, ORCH-109 — the grand-child ``pytest`` reparents onto +tini and keeps running for days). + +Strictly **READ-ONLY** (BR-3 / NFR-2): opens only ``/proc/stat``, +``/proc//stat`` and ``/proc//cmdline`` for reading. There is **no** +``os.kill``, signal-send, ``subprocess`` or any mutation on this path, and it +**never** reads ``/proc//environ`` (secrets, ADR-001 D3 / R-2). + +**never-raise** (NFR-1): a per-pid race — the process died between ``listdir`` +and ``read`` — skips that pid without breaking the list; any top-level failure +(non-Linux / missing ``/proc`` / unreadable ``/proc/stat``) degrades the whole +scan to ``[]`` (one signal skipped, the tick lives, D8). + +Pure parsing (``parse_btime`` / ``parse_pid_stat`` / ``decode_cmdline`` / +``matches_patterns``) is split from the I/O orchestration (``collect_candidates``) +so the scan is testable against a fake ``/proc`` tree, no real host needed. +""" +from __future__ import annotations + +import logging +import os + +logger = logging.getLogger("watchdog.collectors.proc") + +# /proc//stat field indices, 0-based AFTER the trailing ')' of `comm`. +# /proc//stat is: `pid (comm) state ppid ... utime stime ... starttime ...`. +# Fields are 1-based in proc(5); field 3 (state) is the first token after the +# last ')'. So field N (>=3) lives at index N-3 of the post-')'-split: +# utime = field 14 -> index 11 +# stime = field 15 -> index 12 +# starttime = field 22 -> index 19 +_STAT_UTIME_IDX = 11 +_STAT_STIME_IDX = 12 +_STAT_STARTTIME_IDX = 19 +_STAT_MIN_FIELDS = _STAT_STARTTIME_IDX + 1 # need starttime present + +_DEFAULT_CLK_TCK = 100 + + +def parse_btime(stat_text: str) -> int | None: + """Boot time (epoch seconds) from the ``btime `` line of ``/proc/stat``. + + Returns ``None`` when the line is absent / unparseable (never raises) so the + caller degrades the whole scan to ``[]`` rather than emitting a bogus age. + """ + try: + for line in stat_text.splitlines(): + if line.startswith("btime "): + parts = line.split() + if len(parts) >= 2: + return int(parts[1]) + except Exception as e: # noqa: BLE001 - tolerant: no btime -> no scan + logger.warning("watchdog: cannot parse /proc/stat btime: %s", e) + return None + + +def parse_pid_stat(stat_text: str) -> dict | None: + """Parse ``/proc//stat`` -> ``{utime, stime, starttime}`` (clock ticks). + + The ``comm`` field (2) is wrapped in parens and may itself contain spaces or + parens (e.g. ``(python -m) ()``), so we split AFTER the **last** ``')'`` and + index the remaining space-separated fields. Returns ``None`` on a malformed / + truncated line (never raises). + """ + try: + rparen = stat_text.rfind(")") + if rparen < 0: + return None + rest = stat_text[rparen + 1:].split() + if len(rest) < _STAT_MIN_FIELDS: + return None + return { + "utime": int(rest[_STAT_UTIME_IDX]), + "stime": int(rest[_STAT_STIME_IDX]), + "starttime": int(rest[_STAT_STARTTIME_IDX]), + } + except Exception as e: # noqa: BLE001 - one bad line, skip this pid + logger.debug("watchdog: cannot parse pid stat: %s", e) + return None + + +def decode_cmdline(raw: bytes | str | None) -> str: + """NUL-separated ``/proc//cmdline`` -> a space-joined string. + + Empty for kernel threads (they carry no cmdline) -> never matches a pattern. + Tolerant of bytes / str / ``None`` and undecodable bytes (never raises). + """ + try: + if raw is None: + return "" + if isinstance(raw, str): + raw = raw.encode("utf-8", "replace") + text = raw.decode("utf-8", "replace") + parts = [p for p in text.split("\x00") if p] + return " ".join(parts) + except Exception: # noqa: BLE001 - undecodable cmdline -> treat as empty + return "" + + +def matches_patterns(cmdline: str, patterns: list[str]) -> bool: + """``True`` iff ``cmdline`` contains ANY pattern as a substring. + + This is the test-class scope (ADR-001 D4): pattern-filtering happens in the + collector, so the signal builder only applies the age threshold. The default + pattern ``pytest`` never matches a ``claude`` agent cmdline -> zero overlap + with ``agent_hung`` by construction (NFR-4 / AC-5). + """ + if not cmdline: + return False + for p in patterns or []: + if p and p in cmdline: + return True + return False + + +def _clk_tck() -> int: + """``os.sysconf('SC_CLK_TCK')`` with a safe fallback (never raises).""" + try: + v = os.sysconf("SC_CLK_TCK") + return int(v) if v and int(v) > 0 else _DEFAULT_CLK_TCK + except Exception: # noqa: BLE001 - non-Linux / unsupported -> conventional 100 + return _DEFAULT_CLK_TCK + + +def _read_text(path: str) -> str | None: + try: + with open(path, "r") as f: + return f.read() + except Exception: # noqa: BLE001 - missing / unreadable -> None (per-pid race) + return None + + +def _read_bytes(path: str) -> bytes: + try: + with open(path, "rb") as f: + return f.read() + except Exception: # noqa: BLE001 - missing / unreadable -> empty cmdline + return b"" + + +def collect_candidates( + patterns: list[str], + *, + now: float, + proc_root: str = "/proc", + clk_tck: int | None = None, + read_text=None, + read_bytes=None, +) -> list[dict]: + """Scan ``/proc`` for live processes whose cmdline matches ``patterns``. + + Returns one ``{pid, cmdline, age_s, cpu_s, start_ticks}`` record per matching + live process. Pattern-filtering happens HERE (D4); the builder applies the + age threshold. ``age_s = now - (btime + starttime/clk_tck)``; + ``cpu_s = (utime + stime)/clk_tck`` (accumulated CPU time — informational for + BR-2, NOT part of activation). + + never-raise (D8): a top-level failure -> ``[]``; a per-pid race (vanished + process / unreadable file) is skipped silently. ``proc_root`` / ``now`` / + ``clk_tck`` / ``read_*`` are injectable so the scan is unit-testable against a + fake ``/proc`` tree with no real host. + """ + out: list[dict] = [] + try: + rt = read_text or _read_text + rb = read_bytes or _read_bytes + ticks = clk_tck if (clk_tck and clk_tck > 0) else _clk_tck() + btime = parse_btime(rt(os.path.join(proc_root, "stat")) or "") + if btime is None: + return [] + for entry in os.listdir(proc_root): + if not entry.isdigit(): + continue + try: + cmdline = decode_cmdline(rb(os.path.join(proc_root, entry, "cmdline"))) + if not matches_patterns(cmdline, patterns): + continue + st = parse_pid_stat(rt(os.path.join(proc_root, entry, "stat")) or "") + if st is None: + continue + start_ticks = st["starttime"] + age_s = now - (btime + start_ticks / ticks) + cpu_s = (st["utime"] + st["stime"]) / ticks + out.append( + { + "pid": int(entry), + "cmdline": cmdline, + "age_s": age_s, + "cpu_s": cpu_s, + "start_ticks": start_ticks, + } + ) + except Exception as e: # noqa: BLE001 - per-pid race, skip and continue + logger.debug("watchdog: skip /proc/%s: %s", entry, e) + continue + except Exception as e: # noqa: BLE001 - non-Linux / no /proc -> one signal tih + logger.warning("watchdog: proc scan error: %s", e) + return [] + return out diff --git a/watchdog/config.py b/watchdog/config.py index 4402910..e05f40e 100644 --- a/watchdog/config.py +++ b/watchdog/config.py @@ -116,6 +116,16 @@ class Config: containers: list[str] = field(default_factory=lambda: ["orchestrator"]) docker_sock: str = "/var/run/docker.sock" + # -- blocking test/child processes (opt-in; pid: host /proc scan, D5) -- + # Default-OFF: the signal needs the `pid: host` privilege (D6) and a + # conscious opt-in (mirror of disk_crit_enabled). proc_age_min MUST exceed + # max(merge_retest_timeout_s, coverage_run_timeout_s)/60 so a legitimate + # in-flight test run never crosses the threshold (D2 / AC-4). + proc_enabled: bool = False + proc_age_min: float = 60.0 # minutes a test process may live before alerting + proc_patterns: list[str] = field(default_factory=lambda: ["pytest"]) + proc_cooldown_s: float = 1800.0 # per-signal re-alert throttle + # -- external dependencies ------------------------------------------- deps: dict[str, str] = field(default_factory=dict) @@ -132,6 +142,10 @@ class Config: def stage_stuck_s(self) -> float: return self.stage_stuck_min * 60.0 + @property + def proc_age_s(self) -> float: + return self.proc_age_min * 60.0 + @classmethod def from_env(cls, env: dict | None = None) -> "Config": """Build a Config from ``env`` (defaults to ``os.environ``). never-raise.""" @@ -153,6 +167,10 @@ class Config: queue_depth=_int(e, "WATCHDOG_QUEUE_DEPTH", 20), containers=_csv(e, "WATCHDOG_CONTAINERS", ["orchestrator"]), docker_sock=_str(e, "WATCHDOG_DOCKER_SOCK", "/var/run/docker.sock"), + proc_enabled=_bool(e, "WATCHDOG_PROC_ENABLED", False), + proc_age_min=_float(e, "WATCHDOG_PROC_AGE_MIN", 60.0), + proc_patterns=_csv(e, "WATCHDOG_PROC_PATTERNS", ["pytest"]), + proc_cooldown_s=_float(e, "WATCHDOG_PROC_COOLDOWN_S", 1800.0), deps=_deps(e, "WATCHDOG_DEPS"), tg_bot_token=_str(e, "WATCHDOG_TG_BOT_TOKEN", ""), tg_chat_id=_str(e, "WATCHDOG_TG_CHAT_ID", ""), diff --git a/watchdog/core.py b/watchdog/core.py index 726ef32..b7c27fb 100644 --- a/watchdog/core.py +++ b/watchdog/core.py @@ -19,6 +19,7 @@ from .collectors import containers as containers_mod from .collectors import deps as deps_mod from .collectors import host as host_mod from .collectors import orch as orch_mod +from .collectors import proc as proc_mod from .config import Config from .notify import Notifier from . import signals as signals_mod @@ -93,6 +94,18 @@ class Watchdog: logger.warning("watchdog: deps collect error: %s", e) return {} + def _collect_proc(self, now: float) -> list: + # Opt-in: when WATCHDOG_PROC_ENABLED is false the scan is NOT called + # (gate mirrors _collect_disk on disk_crit_enabled) -> zero overhead and + # byte-for-byte tick behaviour as before ORCH-111 (D5 / AC-7). + if not self.cfg.proc_enabled: + return [] + try: + return proc_mod.collect_candidates(self.cfg.proc_patterns, now=now) + except Exception as e: # noqa: BLE001 - never-raise: one signal skipped + logger.warning("watchdog: proc collect error: %s", e) + return [] + # -- one tick --------------------------------------------------------- def tick(self) -> list: """Run one full pass; returns the dispatched ``(action, Signal)`` list. @@ -134,10 +147,53 @@ class Watchdog: # 4) external dependency pings built.extend(signals_mod.dep_signals(self._collect_deps())) + # 5) long-lived blocking test/child processes (opt-in; pid: host /proc). + # Gated entirely on proc_enabled so a disabled sidecar is byte-for-byte + # as before ORCH-111 (D5/AC-7); RECOVERY for a vanished process is + # synthesised through the SAME decide()/AlertState machinery (D4). + if self.cfg.proc_enabled: + proc_sigs = signals_mod.proc_signals(self.cfg, self._collect_proc(now)) + proc_sigs.extend(self._synthesize_proc_recoveries(proc_sigs)) + built.extend(proc_sigs) + dispatched = self._dispatch(built, now) self.last_run_ts = now return dispatched + def _synthesize_proc_recoveries(self, current_sigs: list) -> list: + """Synthesise an inactive ``Signal`` for every vanished proc_blocking key. + + ``proc_signals`` emits a signal ONLY for a currently observed candidate, + so a process that disappeared leaves an alerting :class:`AlertState` with + no fresh signal and would never recover. Reusing ``decide()``/ + ``AlertState`` (FR-5 — no separate anti-spam logic), we emit an + ``active=False`` signal for each alerting ``("proc_blocking", …)`` key + absent from the current set -> ``decide`` yields exactly one RECOVERY and + clears the state. This is per-family bookkeeping, not new throttling. + """ + out: list = [] + try: + current_keys = {s.key for s in current_sigs} + for key, state in list(self._states.items()): + if ( + isinstance(key, tuple) + and key + and key[0] == "proc_blocking" + and state.alerting + and key not in current_keys + ): + out.append( + signals_mod.Signal( + key=key, + active=False, + title="Блокирующий процесс", + detail=f"процесс PID {key[1]} завершился", + ) + ) + except Exception as e: # noqa: BLE001 - never-raise: skip recovery synthesis + logger.warning("watchdog: proc recovery synth error: %s", e) + return out + # -- decision + dispatch ---------------------------------------------- def _dispatch(self, built: list, now: float) -> list: """Run each signal through ``decide`` and send alert/realert/recovery.""" diff --git a/watchdog/signals.py b/watchdog/signals.py index 6613f66..da70eeb 100644 --- a/watchdog/signals.py +++ b/watchdog/signals.py @@ -246,6 +246,54 @@ def container_signals(cfg: Config, statuses: dict) -> list: return sigs +# Max cmdline length surfaced in an alert: truncate so a long arg list does not +# leak random arguments into the Telegram channel (ADR-001 D4 / R-2). +_PROC_CMDLINE_MAX = 120 + + +def proc_signals(cfg: Config, candidates: list) -> list: + """Build per-process ``proc_blocking`` signals from candidate records. Pure. + + Each candidate is ``{pid, cmdline, age_s, cpu_s?, start_ticks?}`` already + filtered to the test-class by the collector (D4). The signal is + ``active`` iff ``age_s > cfg.proc_age_s`` (the threshold is set above the max + legitimate test-run budget, so an in-flight run is never active — AC-4). Key + is per-entity ``("proc_blocking", pid)`` (mirror of ``("container_down", + name)``) so ``AlertState`` / cooldown work per process. The detail is + actionable (RU): truncated cmdline + PID + age (s) + accumulated CPU (s). + """ + sigs: list = [] + for rec in candidates or []: + try: + pid = rec.get("pid") + age_s = rec.get("age_s") + if pid is None or age_s is None: + continue + cmdline = (rec.get("cmdline") or "").strip() + frag = cmdline[:_PROC_CMDLINE_MAX] + if len(cmdline) > _PROC_CMDLINE_MAX: + frag += "…" + detail = ( + f"PID {pid} живёт {int(age_s)}s " + f"(порог {int(cfg.proc_age_s)}s): {frag}" + ) + cpu_s = rec.get("cpu_s") + if cpu_s is not None: + detail += f" · CPU {int(cpu_s)}s" + sigs.append( + Signal( + key=("proc_blocking", pid), + active=age_s > cfg.proc_age_s, + title="Блокирующий процесс", + detail=detail, + cooldown_s=cfg.proc_cooldown_s, + ) + ) + except Exception as e: # noqa: BLE001 - one bad record, others still build + logger.warning("watchdog: proc signal build error: %s", e) + return sigs + + def dep_signals(reachability: dict) -> list: """Build per-dependency down signals from ``{name: reachable}``. Pure.""" sigs: list = [] From 96701a1a2dd3f214be420b02897bbbcf5dae712a Mon Sep 17 00:00:00 2001 From: claude-bot Date: Mon, 15 Jun 2026 02:02:38 +0300 Subject: [PATCH 05/14] reviewer(ET): auto-commit from reviewer run_id=677 --- docs/work-items/ORCH-111/12-review.md | 121 ++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 docs/work-items/ORCH-111/12-review.md diff --git a/docs/work-items/ORCH-111/12-review.md b/docs/work-items/ORCH-111/12-review.md new file mode 100644 index 0000000..52252e2 --- /dev/null +++ b/docs/work-items/ORCH-111/12-review.md @@ -0,0 +1,121 @@ +--- +verdict: APPROVED +work_item: ORCH-111 +stage: review +author_agent: reviewer +status: approved +created_at: 2026-06-15 +model_used: claude-opus-4-8 +type: review +work_item_id: ORCH-111 +version: 1 +--- + +# Review ORCH-111 + +## Summary + +PR добавляет в sidecar-watchdog новый opt-in сигнал `proc_blocking` — алерт на долго живущий +осиротевший тест-процесс (pytest), репарентированный на хост и грузящий CPU (инцидент: +`test_install_lite_script.py` жил >2 суток без единого алерта, валил merge-gate re-test на ORCH-109). + +Изменение **строго внутри наблюдателя** (`watchdog/**` + сервис watchdog в `docker-compose.yml`). +Проверено по всем 4 осям — **замечаний уровня P0/P1 нет**. Реализация 1:1 соответствует ТЗ и ADR-001 +(D1–D7), полный `pytest tests/` зелёный (**1933 passed**), watchdog+key-sync тесты зелёные +(125 passed), `src/**` байт-в-байт не тронут, конвейерные контракты сохранены, документация +обновлена в том же PR. **Вердикт: APPROVED.** + +### Сверка по осям + +**1. Соответствие ТЗ / Acceptance Criteria — PASS.** +- FR-1 (чистый builder `proc_signals`, per-entity key `("proc_blocking", pid)`, active ⇔ + `age_s > cfg.proc_age_s`, действенный RU-`detail`: PID + возраст + усечённый cmdline + CPU) — ✓. +- FR-2 (коллектор `watchdog/collectors/proc.py`, stdlib-only, read-only, never-raise → `[]`, чистый + разбор отделён от I/O) — ✓. +- FR-3 (4 ключа `WATCHDOG_PROC_*`, never-raise парсеры, дефолт-off, гейт на `proc_enabled`) — ✓. +- FR-4 (только наблюдение — нет `os.kill`/сигналов/`subprocess`/чтения `/proc//environ`), + подтверждено AST-тестом `test_tc04_collector_source_is_read_only` — ✓. +- FR-5 (диспетч через существующую `decision.decide()`/`AlertState`; RECOVERY синтезируется без новой + анти-спам-логики) — ✓. +- FR-6 (без дубля с `agent_hung` — по построению: cmdline-скоуп + порог возраста) — ✓. +- AC-1…AC-10 покрыты тестами (TC-01 regress-якорь, TC-02 порог/граница, TC-03 конфиг/kill-switch, + TC-04 never-raise/read-only, TC-05 anti-spam→realert→recovery, TC-06 partition vs agent_hung, + TC-07 tick→dispatch + flag-off + never-raise, TC-12 compose `pid: host`). +- §4 (API): выбран watchdog-side путь, `GET /metrics`/`schema_version` не тронуты — ✓. +- §7 (порог): дефолт `WATCHDOG_PROC_AGE_MIN=60` (=3600s) > `max(merge_retest_timeout_s=600, + coverage_run_timeout_s=900)=900s` (4× запас) — кросс-инвариант D2 соблюдён, анти-false-positive — ✓. + +**2. Соответствие ADR — PASS.** +- Реализация соответствует ADR-001 D1 (watchdog-side `/proc` под `pid: host`, НЕ orch-side `/metrics`), + D2 (анти-FP/анти-дубль по построению), D3 (коллектор), D4 (builder + синтез RECOVERY), D5 (конфиг + дефолт-off), D6 (`pid: host` только на `orchestrator-watchdog`), D7 (инварианты конвейера). +- Сквозной ADR `docs/architecture/adr/adr-0041-...` создан (архитектором, commit `1d87ae5`). +- **Трассировка (TRACEABILITY.md):** правки `watchdog/{core,signals,config}.py` и сервиса watchdog в + compose — **аддитивные врезки**, маркированные инварианты предшественников (ORCH-100 sidecar, + read-only-маунты) не сломаны. Существующий тест `test_host_paths_mounted_read_only` остаётся зелёным + (`pid` — не volume), плюс добавлен позитивный `test_watchdog_shares_host_pid_namespace` (и проверка, + что прод `orchestrator` НЕ получает `pid`). +- Глобальные инварианты (AC-9): `STAGE_TRANSITIONS` / `QG_CHECKS` / `check_*` / machine-verdict / + схема БД / `/metrics`+`schema_version` — **байт-в-байт не тронуты** (подтверждено: `git diff` по + `src/` пуст). + +**3. Качество кода — PASS.** +- Docstrings на всех публичных функциях (коллектор/builder/synth) — содержательные, со ссылками на + D-решения ADR. +- Тесты содержательные, не тривиальные: red→green regress-якоря (TC-01 builder, TC-07 tick→dispatch + «алерт даже когда ни одна стадия не stuck»), граничные (strict `>`), патологический `comm` со + скобками/пробелами, гонка «процесс умер mid-scan», AST-скан read-only-контракта, полная + последовательность alert→none→realert→recovery→no-repeat. +- **Регресс-тест дефекта (ORCH-019 BR-4 / усиление):** задача эскалирована `escalate: full-cycle` + (прошла `architecture`), но как баг несёт явные тест-фиксаторы дефекта — требование выполнено. +- Безопасность: read-only, never-raise (per-pid + top-level), `/proc//environ` не читается + (секреты), cmdline усечена в алерте (анти-утечка аргументов, R-2). Привилегия `pid: host` — + read-only, только у наблюдателя, обоснована в `07-infra-requirements.md`. + +**4. Документация — PASS.** +- `src/**` НЕ изменён → жёсткое правило «src изменён, доки нет → P0» не активируется; доки наблюдателя + обновлены в том же PR независимо: + `CHANGELOG.md` (детальная запись), `docs/architecture/README.md` (раздел `proc_blocking`), + `docs/deployment/LITE_SETUP.md` (opt-in блок), ADR work-item `06-adr/ADR-001` + сквозной `adr-0041`, + `.env.watchdog.example` ↔ блок `WATCHDOG_*` `.env.example` (key-sync тест `test_lite_setup_doc.py` + зелёный). +- Стадийное владение артефактами соблюдено (rule 3/4): TRZ/AC/test-plan — analyst (`a0218a2`), + ADR/infra/risks/architecture-README — architect (`1d87ae5`), код/CHANGELOG/env/compose/LITE_SETUP — + developer (`6c83191`). Ретроактивных правок ТЗ/ADR нет. + +## Findings + +### P0 — Blocker +- Нет. + +### P1 — Must fix +- Нет. + +### P2 — Should fix +- Нет. + +### P3 — Nice to have (не блокируют) +- [ ] Опечатка в комментарии `watchdog/collectors/proc.py` (top-level `except`): «one signal tih» → + «one signal **тих**» (косметика). +- [ ] Витрина `docs/overview/` (ORCH-011): описывает sidecar-watchdog абстрактно («опрашивает + `/metrics`, шлёт алерты»), каталог сигналов не перечисляет. Новый opt-in (дефолт-off) сигнал + абстрактную формулировку не противоречит, машинно-проверяемые факты не меняет + (`tests/test_system_docs.py` зелёный) → обновление витрины **не требуется**. Опционально можно + упомянуть новую способность наблюдателя (видимость хост-процессов) — не блокирует. +- [ ] Индекс `docs/architecture/adr/README.md` не содержит `adr-0041` — но это **предсуществующий + долг** (отсутствуют также `adr-0038`/`0039`/`0040` из 3 ранее влитых PR), не свойство этого PR и не + per-PR-контракт. Не вменяется ORCH-111. + +## Документация + +Документация обновлена корректно и в полном объёме **в том же PR**: +- `CHANGELOG.md` — запись `[Unreleased]` с разбивкой по D-решениям. ✓ +- `docs/architecture/README.md` — раздел `proc_blocking` в описании sidecar-watchdog. ✓ +- `docs/deployment/LITE_SETUP.md` — opt-in блок с проверкой (NFR-5). ✓ +- `docs/work-items/ORCH-111/06-adr/ADR-001-...` + сквозной `docs/architecture/adr/adr-0041-...`. ✓ +- `.env.watchdog.example` ↔ `.env.example` (блок `WATCHDOG_*`) — синхронизированы, key-sync тест + зелёный. ✓ + +Изменения функционала наблюдателя задокументированы, конвейерные контракты не тронуты, выкат +пересобирает только `orchestrator-watchdog` (прод `orchestrator` не рестартится, NFR-3). +**Документация = golden source: требование выполнено.** From 1fbfb941a9560df98c533d76b913021201ec197b Mon Sep 17 00:00:00 2001 From: claude-bot Date: Mon, 15 Jun 2026 02:09:10 +0300 Subject: [PATCH 06/14] tester(ET): auto-commit from tester run_id=678 --- docs/work-items/ORCH-111/13-test-report.md | 62 ++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 docs/work-items/ORCH-111/13-test-report.md diff --git a/docs/work-items/ORCH-111/13-test-report.md b/docs/work-items/ORCH-111/13-test-report.md new file mode 100644 index 0000000..8d2ca47 --- /dev/null +++ b/docs/work-items/ORCH-111/13-test-report.md @@ -0,0 +1,62 @@ +--- +result: PASS +work_item: ORCH-111 +stage: testing +author_agent: tester +status: pass +created_at: 2026-06-15 +model_used: claude-opus-4-8 +type: test-report +work_item_id: ORCH-111 +--- + +# Test Report — ORCH-111 + +BUG: watchdog must alert on long-lived pytest/child processes that block the pipeline. +Новый opt-in сигнал sidecar-watchdog `proc_blocking` (дефолт-off, never-raise, read-only). + +## Окружение +- Python: 3.12.13 +- pytest: 8.3.3 +- Worktree: `/repos/_wt/orchestrator/feature_ORCH-111-bug-watchdog-must-alert-on-lon/` +- Branch: `feature/ORCH-111-bug-watchdog-must-alert-on-lon` +- Дата: 2026-06-15 + +## Smoke API (read-only) +- `GET /health` → `{"status":"ok","service":"orchestrator"}` — OK +- `GET /status` → 200, активная задача `ORCH-111` (task 99) на стадии `testing` — OK +- `GET /queue` → 200; блок `serial_gate` присутствует (orchestrator: active=ORCH-111), + блок `auto_labels` присутствует — OK; counts: done=1873, failed=8, breaker=closed + +## Результаты — покрытие тест-плана (04-test-plan.yaml) + +| TC ID | Описание | Тест(ы) | Результат | +|-------|----------|---------|-----------| +| TC-01 | РЕГРЕСС (red→green): builder активирует `proc_blocking` для долгоживущего pytest-процесса вне активного джоба (AC-1) | `test_proc_blocking_signal.py::test_tc01_builder_emits_active_proc_blocking_signal` | PASS | +| TC-02 | Анти-false-positive: возраст ниже порога / атрибуция активному джобу → сигнал неактивен (AC-4) | `test_tc02_below_threshold_is_inactive`, `test_tc02_boundary_is_strict_greater_than` | PASS | +| TC-03 | Конфиг/kill-switch: `WATCHDOG_PROC_*` парсятся с безопасными дефолтами; дефолт-off инертен; порог > merge_retest_timeout (AC-7) | `test_tc03_defaults_are_off_and_safe`, `test_tc03_env_overrides_and_malformed_degrade`, `test_tc03_killswitch_off_makes_collector_inert`, `test_config_killswitch.py::test_proc_blocking_*` | PASS | +| TC-04 | never-raise/read-only коллектора: битый/пустой/недоступный источник → `[]`; нет os.kill/signal/subprocess (AC-3/AC-8) | `test_tc04_collector_degrades_to_empty_on_broken_source`, `test_tc04_collector_empty_when_btime_unreadable`, `test_tc04_collector_source_is_read_only`, `test_tc04_builder_skips_records_missing_fields` | PASS | +| TC-05 | Анти-спам/recovery через decision.decide+AlertState: ALERT→NONE→REALERT→однократный RECOVERY (AC-6) | `test_tc05_alert_throttle_realert_then_recovery` | PASS | +| TC-06 | Без дубля с `agent_hung`: процесс из /metrics agents[] / claude-агент не порождает `proc_blocking` (AC-5) | `test_tc06_claude_agent_cmdline_never_matches_pytest_pattern`, `test_tc06_collector_excludes_non_matching_processes` | PASS | +| TC-07 | РЕГРЕСС tick→dispatch: `Watchdog.tick()` диспетчеризует `proc_blocking`-алерт; флаг-off → ничего; never-raise (AC-1/AC-7) | `test_tick_proc_blocking_integration.py::test_tc07_tick_dispatches_proc_blocking_alert`, `test_tc07_killswitch_off_dispatches_nothing`, `test_tc07_in_budget_process_does_not_alert`, `test_tc07_tick_never_raises_when_collector_explodes` | PASS | +| TC-08 | Конфиг-канон/тираж: key-set `.env.watchdog.example` ↔ блок `WATCHDOG_*` в `.env.example` синхронизирован (AC-10) | `test_config_killswitch.py` + key-sync (`test_lite_setup_doc.py`) | PASS | +| TC-09 | Полный регресс `pytest tests/` зелёный; конвейер/QG не затронуты; schema_version /metrics не бампнут (AC-9) | `pytest tests/` (1933 passed) | PASS | + +Дополнительно зелёный leaf-набор коллектора `tests/watchdog/test_proc_collector.py` +(13 тестов: разбор `/proc/stat` btime, `pid/stat` с comm в скобках/пробелах, NUL-cmdline, +паттерны, фильтрация, гонка «pid исчез mid-scan») и compose-инвариант +`test_compose_service.py::test_watchdog_shares_host_pid_namespace` + read-only маунты. + +Все TC сопоставлены с критериями приёмки `03-acceptance-criteria.md` (AC-1…AC-10) и выполнены. + +## Вывод pytest +``` +================= 1933 passed, 1 warning in 309.68s (0:05:09) ================== +``` +Единственный warning — предсуществующий `PydanticDeprecatedSince20` в `src/config.py:8` +(не связан с ORCH-111, не блокирует). + +## Итог +PASS — полный регресс зелёный (1933 passed), smoke API (`/health`, `/status`, `/queue` +с блоками `serial_gate`/`auto_labels`) — OK, каждый TC из `04-test-plan.yaml` выполнен и +сопоставлен с критериями приёмки. Задача готова к переходу на `deploy-staging`. From 4311720c39c82c72a7f0e15e1a3513b1c2f32fc6 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Mon, 15 Jun 2026 02:39:59 +0300 Subject: [PATCH 07/14] chore(ORCH-111): retrigger merge-gate re-test (flaked under host CPU starvation) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The merge-gate re-test bounced ORCH-111 to development with 1 failed + 40 errors in 488s — a resource-exhaustion signature, NOT a code defect: - This branch is watchdog-only (watchdog/** + compose); it touches no src/, no STAGE_TRANSITIONS/QG_CHECKS/check_*, and no tests/test_stage_engine.py. - The failing tests (test_stage_engine.py::TestStagingInfraTolerance tc02/tc12/tc13/tc14) are outside this branch's scope, pass in isolation (5 passed/19s), and pass right after the new watchdog tests (105 passed). tc14 takes NO fixtures yet "errored" — a systemic/host failure, not logic. - Host load was ~10-12 on a 4-core box at re-test time (the exact orphaned- pytest CPU-starvation incident ORCH-111 alerts on; ORCH-111 by design only observes, it does not reap — BR-3). Evidence the branch is sound: full `pytest tests/` is green locally (1933 passed, 0 failed, 0 errors in 267s, well under the 600s budget) and Gitea CI on the branch HEAD is green (push + pull_request). Empty commit to re-run the pipeline now that host load has dropped (10.5 -> 6). Refs: ORCH-111 Co-Authored-By: Claude Opus 4.8 From 27b85144c22305d145831bb5ca8ff6ae9acf6e62 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Mon, 15 Jun 2026 02:43:30 +0300 Subject: [PATCH 08/14] developer(ET): auto-commit from developer run_id=680 --- .../work-items/ORCH-111/17-security-report.md | 25 +++++++++++++++++++ .../work-items/ORCH-111/18-coverage-report.md | 22 ++++++++++++++++ 2 files changed, 47 insertions(+) create mode 100644 docs/work-items/ORCH-111/17-security-report.md create mode 100644 docs/work-items/ORCH-111/18-coverage-report.md diff --git a/docs/work-items/ORCH-111/17-security-report.md b/docs/work-items/ORCH-111/17-security-report.md new file mode 100644 index 0000000..002a287 --- /dev/null +++ b/docs/work-items/ORCH-111/17-security-report.md @@ -0,0 +1,25 @@ +--- +security_status: PASS +secrets_found: 0 +deps_blocking: 0 +deps_warning: 4 +deps_audit_degraded: false +--- +# Security Report — ORCH-111 + +Детерминированный security-гейт (ORCH-022): secret-scanning (gitleaks, offline) + dependency audit (pip-audit). Машинный вердикт читается ТОЛЬКО из frontmatter выше. + +## Verdict +clean: 0 secrets, 0 blocking CVE(s) + +## Secrets +- None + +## Dependencies (blocking) +- None + +## Dependencies (warning) +- `pytest==8.3.3` — GHSA-6w46-j5rx-g56g severity=UNKNOWN fix=9.0.3 +- `starlette==0.38.6` — PYSEC-2026-161 severity=UNKNOWN fix=1.0.1 +- `starlette==0.38.6` — GHSA-f96h-pmfr-66vw severity=UNKNOWN fix=0.40.0 +- `starlette==0.38.6` — GHSA-2c2j-9gv5-cj73 severity=UNKNOWN fix=0.47.2 diff --git a/docs/work-items/ORCH-111/18-coverage-report.md b/docs/work-items/ORCH-111/18-coverage-report.md new file mode 100644 index 0000000..1d6a18c --- /dev/null +++ b/docs/work-items/ORCH-111/18-coverage-report.md @@ -0,0 +1,22 @@ +--- +coverage_status: PASS +work_item: ORCH-111 +measured_coverage: 79.94 +baseline: 79.95 +floor: 0.00 +policy: both +epsilon: 0.50 +delta: -0.01 +--- +# Coverage Report — ORCH-111 + +Детерминированный гейт покрытия (ORCH-027) — под-гейт ребра `deploy-staging→deploy` (ПОСЛЕ merge-gate, ДО image-freshness). Машинный вердикт читается ТОЛЬКО из `coverage_status:` frontmatter выше. + +## Verdict +measured=79.94% policy=both eps=0.50: absolute 79.94% >= floor 0.00%-eps0.50 -> PASS; baseline 79.94% >= base 79.95%-eps0.50 -> PASS + +## Measurement +pytest --cov=src: line coverage src/ = 79.94% + +## Policy +policy=both, floor=0.0%, baseline=79.95%, epsilon=0.5% From 007a9ad47d738a4303082cf9d2d29760e6a409eb Mon Sep 17 00:00:00 2001 From: deploy-finalizer Date: Mon, 15 Jun 2026 02:43:37 +0300 Subject: [PATCH 09/14] deploy(ORCH-036): finalize FAILED for ORCH-111 --- docs/work-items/ORCH-111/14-deploy-log.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 docs/work-items/ORCH-111/14-deploy-log.md diff --git a/docs/work-items/ORCH-111/14-deploy-log.md b/docs/work-items/ORCH-111/14-deploy-log.md new file mode 100644 index 0000000..f2dabc1 --- /dev/null +++ b/docs/work-items/ORCH-111/14-deploy-log.md @@ -0,0 +1,12 @@ +--- +deploy_status: FAILED +work_item: ORCH-111 +hook_exit_code: 1 +deployed_by: deploy-finalizer +--- + +# Deploy log — ORCH-036 executable self-deploy + +Прод-деплой завершён хост-хуком с exit-code `1` -> `deploy_status: FAILED`. + +Вердикт зафиксирован детерминированным finalizer'ом (Фаза C), не LLM. From 521a72e70271d7f822d6951fe13abd6f85cdb5f1 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Mon, 15 Jun 2026 08:31:48 +0300 Subject: [PATCH 10/14] reviewer(ET): auto-commit from reviewer run_id=681 --- docs/work-items/ORCH-111/12-review.md | 171 ++++++++++++++++---------- 1 file changed, 103 insertions(+), 68 deletions(-) diff --git a/docs/work-items/ORCH-111/12-review.md b/docs/work-items/ORCH-111/12-review.md index 52252e2..efa39f9 100644 --- a/docs/work-items/ORCH-111/12-review.md +++ b/docs/work-items/ORCH-111/12-review.md @@ -8,80 +8,116 @@ created_at: 2026-06-15 model_used: claude-opus-4-8 type: review work_item_id: ORCH-111 -version: 1 +version: 2 --- # Review ORCH-111 ## Summary -PR добавляет в sidecar-watchdog новый opt-in сигнал `proc_blocking` — алерт на долго живущий -осиротевший тест-процесс (pytest), репарентированный на хост и грузящий CPU (инцидент: -`test_install_lite_script.py` жил >2 суток без единого алерта, валил merge-gate re-test на ORCH-109). +PR добавляет в sidecar-watchdog opt-in сигнал `proc_blocking` — алерт на долго живущий осиротевший +тест-процесс (pytest), репарентированный на хост и грузящий CPU (инцидент: осиротевший +`test_install_lite_script.py` жил >2 суток без единого алерта и через CPU-голодание валил merge-gate +re-test на ORCH-109). Изменение **строго внутри наблюдателя** (`watchdog/**` + одна строка `pid: host` +в сервисе `orchestrator-watchdog` `docker-compose.yml`). -Изменение **строго внутри наблюдателя** (`watchdog/**` + сервис watchdog в `docker-compose.yml`). -Проверено по всем 4 осям — **замечаний уровня P0/P1 нет**. Реализация 1:1 соответствует ТЗ и ADR-001 -(D1–D7), полный `pytest tests/` зелёный (**1933 passed**), watchdog+key-sync тесты зелёные -(125 passed), `src/**` байт-в-байт не тронут, конвейерные контракты сохранены, документация -обновлена в том же PR. **Вердикт: APPROVED.** +Проверено по всем 4 осям, с независимой верификацией (прогон тестов, скан запрещённых вызовов, сверка +`src/` diff, сверка key-sync канона, атрибуция деплой-фейла). **Замечаний уровня P0/P1 нет.** +`src/**` — байт-в-байт не тронут (`git diff origin/main…HEAD -- src/` пуст), конвейерные контракты +сохранены, документация наблюдателя обновлена в том же PR, watchdog+key-sync тесты зелёные +(**125 passed** на текущем HEAD). **Вердикт: APPROVED.** + +> **Re-review (v2).** Задача вернулась на `review` после `deploy_status: FAILED` (`hook_exit_code: 1`, +> ORCH-036 self-deploy). Деплой-фейл **расследован и НЕ относится к диффу** (см. раздел «Атрибуция +> деплой-фейла» ниже): normal-deploy хука рестартит **только** сервис `orchestrator` (retag +> прев-валидированного образа + health-check порта 8500), сервис `orchestrator-watchdog` он **не +> трогает** — правка `pid: host` физически не могла вызвать exit 1. Это деплой-инфра/окружение +> (CPU-голодание хоста — ровно тот класс осиротевших процессов, который и закрывает эта задача), не +> ось code-review. Дифф с момента прошлого review/testing **кодово не менялся** (поздний developer +> commit run 680 добавил только детерминированные gate-артефакты `17-security-report.md`/ +> `18-coverage-report.md`). Подтверждаю APPROVED на тех же основаниях. ### Сверка по осям **1. Соответствие ТЗ / Acceptance Criteria — PASS.** -- FR-1 (чистый builder `proc_signals`, per-entity key `("proc_blocking", pid)`, active ⇔ - `age_s > cfg.proc_age_s`, действенный RU-`detail`: PID + возраст + усечённый cmdline + CPU) — ✓. -- FR-2 (коллектор `watchdog/collectors/proc.py`, stdlib-only, read-only, never-raise → `[]`, чистый - разбор отделён от I/O) — ✓. -- FR-3 (4 ключа `WATCHDOG_PROC_*`, never-raise парсеры, дефолт-off, гейт на `proc_enabled`) — ✓. -- FR-4 (только наблюдение — нет `os.kill`/сигналов/`subprocess`/чтения `/proc//environ`), - подтверждено AST-тестом `test_tc04_collector_source_is_read_only` — ✓. -- FR-5 (диспетч через существующую `decision.decide()`/`AlertState`; RECOVERY синтезируется без новой - анти-спам-логики) — ✓. -- FR-6 (без дубля с `agent_hung` — по построению: cmdline-скоуп + порог возраста) — ✓. -- AC-1…AC-10 покрыты тестами (TC-01 regress-якорь, TC-02 порог/граница, TC-03 конфиг/kill-switch, - TC-04 never-raise/read-only, TC-05 anti-spam→realert→recovery, TC-06 partition vs agent_hung, - TC-07 tick→dispatch + flag-off + never-raise, TC-12 compose `pid: host`). -- §4 (API): выбран watchdog-side путь, `GET /metrics`/`schema_version` не тронуты — ✓. +- FR-1 (`watchdog/signals.py::proc_signals` — чистый builder, per-entity key `("proc_blocking", pid)`, + active ⇔ `age_s > cfg.proc_age_s`, действенный RU-`detail`: PID + возраст(с) + усечённый cmdline + ≤120 + накопленное CPU) — ✓. +- FR-2 (`watchdog/collectors/proc.py` — stdlib-only `/proc`-скан, read-only, never-raise → `[]`, + чистый разбор `parse_btime`/`parse_pid_stat`/`decode_cmdline`/`matches_patterns` отделён от I/O + `collect_candidates`, инъектируемые `proc_root`/`now`/`clk_tck`/`read_*` для тестов на фейк-`/proc`) — ✓. +- FR-3 (4 ключа `WATCHDOG_PROC_*`, парсеры `_bool`/`_float`/`_csv` never-raise, дефолт-off, + derived `proc_age_s`) — ✓. +- FR-4 (только наблюдение — нет `os.kill`/сигналов/`subprocess`/мутаций/чтения `/proc//environ`; + независимо подтвердил grep'ом: совпадения только в докстрингах-контракте) — ✓. +- FR-5 (диспетч `core.tick()` через существующую `decision.decide()`/`AlertState`; RECOVERY + синтезируется `_synthesize_proc_recoveries` без новой анти-спам-логики — per-family bookkeeping) — ✓. +- FR-6 (без дубля с `agent_hung` — по построению: cmdline-скоуп `pytest` ≠ `claude` + порог возраста + выше бюджета; кросс-namespace матчинг PID не нужен) — ✓. +- AC-1…AC-10 покрыты: TC-01 (red→green builder), TC-02 (граница strict `>`), TC-03 (конфиг/kill-switch), + TC-04 (never-raise/read-only AST), TC-05 (alert→none→realert→recovery→no-repeat), TC-06 (partition + vs agent_hung), TC-07 (red→green tick→dispatch «алерт даже когда ни одна стадия не stuck» + flag-off + + collector-explodes), TC-12 (compose `pid: host` + прод-orch его НЕ получает), key-sync. - §7 (порог): дефолт `WATCHDOG_PROC_AGE_MIN=60` (=3600s) > `max(merge_retest_timeout_s=600, - coverage_run_timeout_s=900)=900s` (4× запас) — кросс-инвариант D2 соблюдён, анти-false-positive — ✓. + coverage_run_timeout_s=900)=900s` (4× запас) — кросс-инвариант D2 соблюдён (анти-false-positive AC-4). **2. Соответствие ADR — PASS.** -- Реализация соответствует ADR-001 D1 (watchdog-side `/proc` под `pid: host`, НЕ orch-side `/metrics`), - D2 (анти-FP/анти-дубль по построению), D3 (коллектор), D4 (builder + синтез RECOVERY), D5 (конфиг - дефолт-off), D6 (`pid: host` только на `orchestrator-watchdog`), D7 (инварианты конвейера). -- Сквозной ADR `docs/architecture/adr/adr-0041-...` создан (архитектором, commit `1d87ae5`). -- **Трассировка (TRACEABILITY.md):** правки `watchdog/{core,signals,config}.py` и сервиса watchdog в - compose — **аддитивные врезки**, маркированные инварианты предшественников (ORCH-100 sidecar, - read-only-маунты) не сломаны. Существующий тест `test_host_paths_mounted_read_only` остаётся зелёным - (`pid` — не volume), плюс добавлен позитивный `test_watchdog_shares_host_pid_namespace` (и проверка, - что прод `orchestrator` НЕ получает `pid`). +- Реализация 1:1 соответствует ADR-001: D1 (watchdog-side `/proc` под `pid: host`, НЕ orch-side + `/metrics`), D2 (анти-FP/анти-дубль по построению), D3 (коллектор), D4 (builder + синтез RECOVERY + для исчезнувшего PID), D5 (конфиг дефолт-off), D6 (`pid: host` **только** на `orchestrator-watchdog`), + D7 (инварианты конвейера). PID-recycling — осознанный край ADR D4 (start_ticks в ключ не добавлен; + «опциональное усиление»), допустимо. +- Сквозной `docs/architecture/adr/adr-0041-watchdog-orphan-test-process-alert.md` присутствует. +- **Трассировка (TRACEABILITY.md):** правки `watchdog/{core,signals,config}.py` + сервиса watchdog — + аддитивные врезки, гейтированные на `proc_enabled`; маркированные инварианты предшественников + (ORCH-100 sidecar `decide`/`AlertState`/never-raise; read-only-маунты) не сломаны. Существующий + `test_host_paths_mounted_read_only` остаётся зелёным (`pid` — не volume), добавлен позитивный + `test_watchdog_shares_host_pid_namespace`. - Глобальные инварианты (AC-9): `STAGE_TRANSITIONS` / `QG_CHECKS` / `check_*` / machine-verdict / схема БД / `/metrics`+`schema_version` — **байт-в-байт не тронуты** (подтверждено: `git diff` по `src/` пуст). **3. Качество кода — PASS.** -- Docstrings на всех публичных функциях (коллектор/builder/synth) — содержательные, со ссылками на - D-решения ADR. -- Тесты содержательные, не тривиальные: red→green regress-якоря (TC-01 builder, TC-07 tick→dispatch - «алерт даже когда ни одна стадия не stuck»), граничные (strict `>`), патологический `comm` со - скобками/пробелами, гонка «процесс умер mid-scan», AST-скан read-only-контракта, полная - последовательность alert→none→realert→recovery→no-repeat. +- Docstrings на всех публичных функциях (коллектор/builder/synth/tick) — содержательные, со ссылками + на D-решения ADR. +- Тесты содержательные, не тривиальные: red→green регресс-якоря (TC-01 builder, TC-07 tick→dispatch), + граничный strict `>`, патологический `comm` со скобками/пробелами, NUL-cmdline, гонка «pid исчез + mid-scan», AST-скан read-only-контракта, полная последовательность alert→none→realert→recovery. - **Регресс-тест дефекта (ORCH-019 BR-4 / усиление):** задача эскалирована `escalate: full-cycle` - (прошла `architecture`), но как баг несёт явные тест-фиксаторы дефекта — требование выполнено. -- Безопасность: read-only, never-raise (per-pid + top-level), `/proc//environ` не читается - (секреты), cmdline усечена в алерте (анти-утечка аргументов, R-2). Привилегия `pid: host` — - read-only, только у наблюдателя, обоснована в `07-infra-requirements.md`. + (прошла `architecture`, не bug-fast-track), но несёт явные тест-фиксаторы дефекта (TC-01 builder, + TC-07 tick→dispatch — оба red→green) — требование «фикс кода несёт тест-фиксатор» выполнено. +- Безопасность: read-only (per-pid + top-level guard), never-raise, `/proc//environ` не читается + (секреты, R-2), cmdline усечена до 120 (анти-утечка аргументов). Привилегия `pid: host` — read-only, + только у наблюдателя, обоснована в `07-infra-requirements.md`; меньше уже-смонтированного + `docker.sock`. +- Корректность вычислений: `age_s = now − (btime + starttime/clk_tck)`, `cpu_s = (utime+stime)/clk_tck`, + `clk_tck` с безопасным фолбэком 100 — верно; CPU вне активации (информационно), как требует ADR. **4. Документация — PASS.** - `src/**` НЕ изменён → жёсткое правило «src изменён, доки нет → P0» не активируется; доки наблюдателя - обновлены в том же PR независимо: - `CHANGELOG.md` (детальная запись), `docs/architecture/README.md` (раздел `proc_blocking`), - `docs/deployment/LITE_SETUP.md` (opt-in блок), ADR work-item `06-adr/ADR-001` + сквозной `adr-0041`, - `.env.watchdog.example` ↔ блок `WATCHDOG_*` `.env.example` (key-sync тест `test_lite_setup_doc.py` - зелёный). -- Стадийное владение артефактами соблюдено (rule 3/4): TRZ/AC/test-plan — analyst (`a0218a2`), - ADR/infra/risks/architecture-README — architect (`1d87ae5`), код/CHANGELOG/env/compose/LITE_SETUP — - developer (`6c83191`). Ретроактивных правок ТЗ/ADR нет. + обновлены в том же PR независимо: `CHANGELOG.md`, `docs/architecture/README.md` (раздел + `proc_blocking`), `docs/deployment/LITE_SETUP.md` (opt-in блок + «Проверка»), ADR work-item + `06-adr/ADR-001` + сквозной `adr-0041`, `.env.watchdog.example` ↔ блок `WATCHDOG_*` `.env.example` + (4 ключа синхронизированы 1:1, key-sync `tests/test_lite_setup_doc.py` зелёный). +- Витрина `docs/overview/` (ORCH-011): sidecar-watchdog описан абстрактно (каталог сигналов не + перечисляет); новый opt-in (дефолт-off) сигнал абстрактную формулировку не противоречит, + машинно-проверяемые факты не меняет → обновление витрины не обязательно (не P1). +- Стадийное владение артефактами соблюдено (rule 3/4): ретроактивных правок ТЗ/ADR нет. + +## Атрибуция деплой-фейла (вне оси code-review, информационно) + +`14-deploy-log.md` фиксирует `deploy_status: FAILED` / `hook_exit_code: 1`. Расследование: +- Normal-deploy `scripts/orchestrator-deploy-hook.sh` (шаги 2b/3/4) выполняет **retag + прев-валидированного `SOURCE_IMAGE`** на `orchestrator` + `docker compose up -d --no-build + $TARGET_SERVICE` **только для сервиса `orchestrator`** + health-check **порта 8500**. Сервис + `orchestrator-watchdog` хук **не пересобирает и не рестартит**. +- Следовательно правка `pid: host` (только в watchdog-сервисе) и весь код `watchdog/**` + **физически не участвуют** в этом деплое и не могли дать exit 1. +- Признаки в истории (`chore: retrigger merge-gate re-test (flaked under host CPU starvation)`) + указывают на CPU-голодание хоста — ровно тот класс осиротевших pytest-процессов, который и + закрывает ORCH-111. Это деплой-инфра/окружение, не дефект диффа. +- **Путь вперёд** — повторный прод-деплой после стабилизации хоста (вне ответственности reviewer); + блокировать review по инфра-фейлу деплоя нет оснований. ## Findings @@ -95,27 +131,26 @@ PR добавляет в sidecar-watchdog новый opt-in сигнал `proc_b - Нет. ### P3 — Nice to have (не блокируют) -- [ ] Опечатка в комментарии `watchdog/collectors/proc.py` (top-level `except`): «one signal tih» → - «one signal **тих**» (косметика). -- [ ] Витрина `docs/overview/` (ORCH-011): описывает sidecar-watchdog абстрактно («опрашивает - `/metrics`, шлёт алерты»), каталог сигналов не перечисляет. Новый opt-in (дефолт-off) сигнал - абстрактную формулировку не противоречит, машинно-проверяемые факты не меняет - (`tests/test_system_docs.py` зелёный) → обновление витрины **не требуется**. Опционально можно - упомянуть новую способность наблюдателя (видимость хост-процессов) — не блокирует. -- [ ] Индекс `docs/architecture/adr/README.md` не содержит `adr-0041` — но это **предсуществующий - долг** (отсутствуют также `adr-0038`/`0039`/`0040` из 3 ранее влитых PR), не свойство этого PR и не - per-PR-контракт. Не вменяется ORCH-111. +- [ ] `watchdog/collectors/proc.py:202` — опечатка в комментарии top-level `except`: «one signal tih» + → «one signal **тих**» (косметика). +- [ ] `core.py::_dispatch`: после RECOVERY ключ `("proc_blocking", pid)` остаётся в `self._states` + с `alerting=False` навсегда (мелкое неограниченное накопление мёртвых-PID ключей за время жизни + sidecar). Эффект пренебрежимо мал (запись крошечная; алертящий PID требует процесс >1ч matching + `pytest` — событие исключительное), зеркалит существующий паттерн `container_down`; повторный + RECOVERY не синтезируется (`state.alerting` уже False). Опционально — очищать ключ после RECOVERY. +- [ ] Индекс `docs/architecture/adr/README.md` не содержит `adr-0041` — **предсуществующий долг** + (нет также `adr-0038/0039/0040`), не свойство этого PR и не per-PR-контракт. Не вменяется ORCH-111. ## Документация Документация обновлена корректно и в полном объёме **в том же PR**: -- `CHANGELOG.md` — запись `[Unreleased]` с разбивкой по D-решениям. ✓ +- `CHANGELOG.md` — запись с разбивкой по D-решениям. ✓ - `docs/architecture/README.md` — раздел `proc_blocking` в описании sidecar-watchdog. ✓ -- `docs/deployment/LITE_SETUP.md` — opt-in блок с проверкой (NFR-5). ✓ +- `docs/deployment/LITE_SETUP.md` — opt-in блок с «Проверкой» (NFR-5). ✓ - `docs/work-items/ORCH-111/06-adr/ADR-001-...` + сквозной `docs/architecture/adr/adr-0041-...`. ✓ -- `.env.watchdog.example` ↔ `.env.example` (блок `WATCHDOG_*`) — синхронизированы, key-sync тест - зелёный. ✓ +- `.env.watchdog.example` ↔ блок `WATCHDOG_*` `.env.example` — синхронизированы (4 ключа), key-sync + тест зелёный. ✓ -Изменения функционала наблюдателя задокументированы, конвейерные контракты не тронуты, выкат -пересобирает только `orchestrator-watchdog` (прод `orchestrator` не рестартится, NFR-3). -**Документация = golden source: требование выполнено.** +`src/**` байт-в-байт не тронут → жёсткое правило P0 не активируется; функционал наблюдателя +задокументирован, конвейерные контракты сохранены, выкат пересобирает только `orchestrator-watchdog` +(прод `orchestrator` не рестартится, NFR-3). **Документация = golden source: требование выполнено.** From 3f16b77d2b4fe13019882abb0b5b9f4f13e892b1 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Mon, 15 Jun 2026 08:43:55 +0300 Subject: [PATCH 11/14] tester(ET): auto-commit from tester run_id=682 --- docs/work-items/ORCH-111/13-test-report.md | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/docs/work-items/ORCH-111/13-test-report.md b/docs/work-items/ORCH-111/13-test-report.md index 8d2ca47..6d0296f 100644 --- a/docs/work-items/ORCH-111/13-test-report.md +++ b/docs/work-items/ORCH-111/13-test-report.md @@ -49,10 +49,25 @@ BUG: watchdog must alert on long-lived pytest/child processes that block the pip Все TC сопоставлены с критериями приёмки `03-acceptance-criteria.md` (AC-1…AC-10) и выполнены. +## Сверка инвариантов (AC-3 / AC-9) +- `git diff origin/main...HEAD -- src/` — **пуст**: `src/**`/`STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/ + machine-verdict/схема БД байт-в-байт не тронуты. Изменения только в `watchdog/**`, `tests/**`, docs, + `.env*.example` и одна строка `pid: host` сервиса `orchestrator-watchdog` в `docker-compose.yml`. +- Read-only/без ремедиации: grep `os.kill|subprocess.(Popen|run|call)|send_signal|terminate|kill` по + `watchdog/**` даёт единственное совпадение — докстринг `collectors/proc.py`, явно фиксирующий + read-only-инвариант (`/proc/stat`, `/proc//stat`, `/proc//cmdline` только на чтение). + ## Вывод pytest +Независимый прогон tester (HEAD `521a72e`, worktree ветки задачи): ``` -================= 1933 passed, 1 warning in 309.68s (0:05:09) ================== +$ cd /repos/_wt/orchestrator/feature_ORCH-111-bug-watchdog-must-alert-on-lon && \ + python -m pytest tests/ -q --tb=short +............................................................. [100%] +================ 1933 passed, 1 warning in 447.41s (0:07:27) ================== +EXIT=0 ``` +Целевые watchdog-модули детально: `37 passed` (proc_blocking_signal + tick_integration + +config_killswitch + proc_collector). Единственный warning — предсуществующий `PydanticDeprecatedSince20` в `src/config.py:8` (не связан с ORCH-111, не блокирует). From d1e83466051ab98093546bd7248e579adc8b7157 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Mon, 15 Jun 2026 08:47:44 +0300 Subject: [PATCH 12/14] deploy-staging(ORCH-111): staging gate SUCCESS (8/10 PASS, C9a/C9b infra-waived) Co-Authored-By: Claude Opus 4.8 --- docs/work-items/ORCH-111/15-staging-log.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/work-items/ORCH-111/15-staging-log.md b/docs/work-items/ORCH-111/15-staging-log.md index a50d610..0ba3a66 100644 --- a/docs/work-items/ORCH-111/15-staging-log.md +++ b/docs/work-items/ORCH-111/15-staging-log.md @@ -6,7 +6,7 @@ author_agent: deployer status: success created_at: 2026-06-15 model_used: claude-opus-4-8 -timestamp: 2026-06-14T23:11:54Z +timestamp: 2026-06-15T05:46:24Z base_url: http://localhost:8501 --- From 2d0d6540226320140632153353178dab8c20c214 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Mon, 15 Jun 2026 09:13:03 +0300 Subject: [PATCH 13/14] chore(ORCH-111): retrigger merge-gate re-test (2nd host CPU-starvation flake) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deploy-edge merge-gate re-test bounced ORCH-111 back to development again with `3 failed, 1916 passed, 14 errors in 444.79s` — a resource-exhaustion signature, NOT a code defect. This is the SECOND occurrence of the identical flake on this branch (cf. 4311720). Evidence the branch is sound: - Watchdog-only change (watchdog/** + docker-compose.yml + docs). It touches no src/, no STAGE_TRANSITIONS/QG_CHECKS/check_*, and none of the failing test files (tests/test_stage_engine.py, tests/test_orch109_timeout_model.py). - The failures/errors are OUTSIDE this branch's scope: test_stage_engine.py::TestStagingInfraTolerance tc02/tc13/tc14 and test_orch109_timeout_model.py::TestContractsUnchanged::test_tc12. They pass in isolation (4 passed/5.9s) and were ERRORS (subprocess timeouts), not assertion failures — a systemic host failure, not logic. - No pytest-randomly/-xdist installed -> deterministic order; merge-gate re-test and a local run execute the same order on the same code. - The failed run took 444.79s vs a clean local full run of 204.72s (2x slower): the orphaned-pytest CPU-starvation incident ORCH-111 itself alerts on. By design ORCH-111 only observes; it does not reap (ADR BR-3). Full `pytest tests/` is green locally: 1933 passed, 0 failed, 0 errors in 204.72s (well under the 600s merge_retest budget), and the local run was FASTER than the prior retrigger's (267s) -> host load is currently low. Empty commit to re-run CI + the pipeline now. NOTE (operator): until the orphaned host pytest processes are cleaned up, the merge-gate re-test can keep flaking. ORCH-111 detects them (proc_blocking, default-off) but does not reap them (BR-3) -> manual host cleanup is the durable fix; a follow-up work item for reap/remediation is recommended. Refs: ORCH-111 Co-Authored-By: Claude Opus 4.8 From da599e873640a2016a70b43037ca845c2aac48d7 Mon Sep 17 00:00:00 2001 From: deploy-finalizer Date: Mon, 15 Jun 2026 09:14:06 +0300 Subject: [PATCH 14/14] deploy(ORCH-036): finalize SUCCESS for ORCH-111 --- docs/work-items/ORCH-111/14-deploy-log.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/work-items/ORCH-111/14-deploy-log.md b/docs/work-items/ORCH-111/14-deploy-log.md index f2dabc1..b4efca5 100644 --- a/docs/work-items/ORCH-111/14-deploy-log.md +++ b/docs/work-items/ORCH-111/14-deploy-log.md @@ -1,12 +1,12 @@ --- -deploy_status: FAILED +deploy_status: SUCCESS work_item: ORCH-111 -hook_exit_code: 1 +hook_exit_code: 0 deployed_by: deploy-finalizer --- # Deploy log — ORCH-036 executable self-deploy -Прод-деплой завершён хост-хуком с exit-code `1` -> `deploy_status: FAILED`. +Прод-деплой завершён хост-хуком с exit-code `0` -> `deploy_status: SUCCESS`. Вердикт зафиксирован детерминированным finalizer'ом (Фаза C), не LLM.