Files
orchestrator/tests/watchdog/test_proc_blocking_signal.py
claude-bot 2e73ccf090 feat(watchdog): proc_blocking alert for orphaned long-lived test processes
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/<pid>/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 <noreply@anthropic.com>
2026-06-15 02:14:17 +03:00

257 lines
10 KiB
Python

"""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/<pid>/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/<pid>/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)