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>
257 lines
10 KiB
Python
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)
|