From 8099c9a4aaf03612f128d391f432969e47d881de Mon Sep 17 00:00:00 2001 From: operator Date: Wed, 7 Oct 2026 13:48:26 -0400 Subject: [PATCH] feat(watchers): add box stability watcher daemon and test suite --- tests/test_box_stability_watcher.py | 169 +++++ watchers/README.md | 60 ++ .../box-stability-watcher.cpython-314.pyc | Bin 0 -> 38867 bytes watchers/box-stability-watcher.py | 693 ++++++++++++++++++ 4 files changed, 922 insertions(+) create mode 100644 tests/test_box_stability_watcher.py create mode 100644 watchers/README.md create mode 100644 watchers/__pycache__/box-stability-watcher.cpython-314.pyc create mode 100755 watchers/box-stability-watcher.py diff --git a/tests/test_box_stability_watcher.py b/tests/test_box_stability_watcher.py new file mode 100644 index 0000000..02c5696 --- /dev/null +++ b/tests/test_box_stability_watcher.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +"""test_box_stability_watcher.py — Comprehensive unit tests for Box Stability Watcher.""" + +import json +import os +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +REPO_ROOT = Path("/home/super/Projects/NetVM") +WATCHERS_DIR = REPO_ROOT / "watchers" +sys.path.insert(0, str(WATCHERS_DIR)) + +import importlib.util +spec = importlib.util.spec_from_file_location("box_stability_watcher", str(WATCHERS_DIR / "box-stability-watcher.py")) +w = importlib.util.module_from_spec(spec) +spec.loader.exec_module(w) + + +class TestConfigAndSafety(unittest.TestCase): + def test_load_config_defaults(self): + with tempfile.TemporaryDirectory() as td: + non_existent = Path(td) / "missing.json" + cfg = w.load_config(non_existent) + self.assertIn("thresholds", cfg) + self.assertIn("protected_commands", cfg) + self.assertEqual(cfg["thresholds"]["load_warning"], 20.0) + + def test_is_protected(self): + cfg = {"protected_commands": ["sshd", "tailscaled", "tmux", "systemd", "ghostty"]} + self.assertTrue(w.is_protected(1, "systemd", "/sbin/init", cfg)) + self.assertTrue(w.is_protected(os.getpid(), "python3", "some_script", cfg)) + self.assertTrue(w.is_protected(999, "sshd", "/usr/sbin/sshd -D", cfg)) + self.assertTrue(w.is_protected(888, "tmux", "tmux new-session -s main", cfg)) + self.assertTrue(w.is_protected(777, "tailscaled", "/usr/sbin/tailscaled", cfg)) + self.assertFalse(w.is_protected(1234, "muse-bin", "/home/super/.local/bin/muse-bin-1.4.3 resume abc", cfg)) + self.assertFalse(w.is_protected(5678, "chromium", "/usr/lib/chromium/chromium --type=renderer", cfg)) + + +class TestStabilityEvaluation(unittest.TestCase): + def setUp(self): + self.cfg = { + "thresholds": { + "load_warning": 20.0, + "load_critical": 35.0, + "load_emergency": 60.0, + "ram_warning_pct": 80.0, + "ram_critical_pct": 90.0, + "swap_warning_pct": 75.0, + "swap_critical_pct": 85.0, + "process_rss_warning_mb": 2000, + "process_rss_critical_mb": 3000, + }, + "protected_commands": ["sshd", "tmux"] + } + + def test_green_tier(self): + metrics = {"load_1m": 2.5, "ram_used_pct": 30.0, "swap_used_pct": 10.0} + procs = [ + {"pid": 101, "cmdline": "muse-bin", "rss_mb": 400, "cpu_pct": 5.0, "is_protected": False, "nice": 0} + ] + tier, reasons, actions = w.evaluate_stability(metrics, procs, self.cfg) + self.assertEqual(tier, "GREEN") + self.assertEqual(reasons, []) + self.assertEqual(actions, []) + + def test_yellow_tier_elevated_load(self): + metrics = {"load_1m": 22.5, "ram_used_pct": 50.0, "swap_used_pct": 20.0} + procs = [ + {"pid": 102, "cmdline": "python worker", "rss_mb": 500, "cpu_pct": 90.0, "is_protected": False, "nice": 0} + ] + tier, reasons, actions = w.evaluate_stability(metrics, procs, self.cfg) + self.assertEqual(tier, "YELLOW") + self.assertTrue(any("Elevated load/memory" in r for r in reasons)) + self.assertEqual(len(actions), 1) + self.assertEqual(actions[0]["action"], "renice") + self.assertEqual(actions[0]["pid"], 102) + + def test_orange_tier_pause_leaky_process(self): + metrics = {"load_1m": 2.0, "ram_used_pct": 40.0, "swap_used_pct": 10.0} + procs = [ + {"pid": 202, "cmdline": "muse-bin leaky", "rss_mb": 3400, "cpu_pct": 10.0, "is_protected": False, "nice": 0} + ] + tier, reasons, actions = w.evaluate_stability(metrics, procs, self.cfg) + self.assertEqual(tier, "ORANGE") + self.assertTrue(any("exceeded critical RSS" in r for r in reasons)) + self.assertEqual(len(actions), 1) + self.assertEqual(actions[0]["action"], "pause") + self.assertEqual(actions[0]["pid"], 202) + + def test_red_emergency_tier(self): + metrics = {"load_1m": 75.0, "ram_used_pct": 96.0, "swap_used_pct": 94.0} + procs = [ + {"pid": 301, "cmdline": "muse-bin heavy", "rss_mb": 1800, "cpu_pct": 50.0, "is_protected": False, "nice": 0}, + {"pid": 302, "cmdline": "sshd daemon", "rss_mb": 2000, "cpu_pct": 2.0, "is_protected": True, "nice": 0} + ] + tier, reasons, actions = w.evaluate_stability(metrics, procs, self.cfg) + self.assertEqual(tier, "RED") + self.assertTrue(any("Emergency host pressure" in r for r in reasons)) + pause_pids = [a["pid"] for a in actions if a["action"] == "pause"] + self.assertIn(301, pause_pids) + self.assertNotIn(302, pause_pids) + + +class TestSocketIsolation(unittest.TestCase): + def test_distinguishes_user_from_box_launched_agents(self): + # PID 555 is an automated job on default socket + # PID 666 is an agent on the correct fleet socket + # PID 777 is a user-launched interactive agent on default socket + procs = [ + {"pid": 555, "cmdline": "muse-bin auto-work sweep", "tmux_sock": "/tmp/tmux-1000/default", "is_protected": False}, + {"pid": 666, "cmdline": "muse-bin auto-work sweep", "tmux_sock": "/tmp/tmux-muse.sock", "is_protected": False}, + {"pid": 777, "cmdline": "muse-bin interactive chat", "tmux_sock": "/tmp/tmux-1000/default", "is_protected": False}, + ] + box_sessions = {"auto-work": {}} + violations, allowed = w.check_socket_isolation_violations(procs, box_sessions=box_sessions) + self.assertEqual(len(violations), 1) + self.assertEqual(violations[0]["pid"], 555) + self.assertEqual(len(allowed), 1) + self.assertEqual(allowed[0]["pid"], 777) + + +class TestPauseResumeAndExpiry(unittest.TestCase): + @mock.patch("os.kill") + def test_pause_and_state_persistence(self, mock_kill): + with tempfile.TemporaryDirectory() as td: + state_file = Path(td) / "paused.json" + actions = [{"action": "pause", "pid": 4321, "cmd": "muse-bin", "reason": "RSS high"}] + executed = w.execute_actions(actions, state_file=state_file, dry_run=False) + self.assertEqual(len(executed), 1) + mock_kill.assert_called_once_with(4321, 19) # SIGSTOP = 19 + self.assertTrue(state_file.exists()) + data = json.loads(state_file.read_text()) + self.assertIn("4321", data) + + @mock.patch("os.kill") + def test_reconcile_expired_pause(self, mock_kill): + with tempfile.TemporaryDirectory() as td: + state_file = Path(td) / "paused.json" + now = w.now_epoch() + state_data = { + "4321": {"pid": 4321, "cmd": "muse-bin", "paused_at_epoch": now - 70} + } + state_file.write_text(json.dumps(state_data)) + + expired = w.reconcile_paused_processes(state_file=state_file, dry_run=False) + self.assertEqual(len(expired), 1) + self.assertEqual(expired[0]["action"], "cull_expired") + self.assertEqual(expired[0]["pid"], 4321) + mock_kill.assert_called_once_with(4321, 15) # SIGTERM = 15 + remaining = json.loads(state_file.read_text()) + self.assertNotIn("4321", remaining) + + @mock.patch("os.kill") + def test_resume_process(self, mock_kill): + with tempfile.TemporaryDirectory() as td: + state_file = Path(td) / "paused.json" + state_file.write_text(json.dumps({"4321": {"pid": 4321}})) + res = w.resume_process(4321, state_file=state_file) + self.assertTrue(res["success"]) + mock_kill.assert_called_once_with(4321, 18) # SIGCONT = 18 + remaining = json.loads(state_file.read_text()) + self.assertNotIn("4321", remaining) + + +if __name__ == "__main__": + unittest.main() diff --git a/watchers/README.md b/watchers/README.md new file mode 100644 index 0000000..6105eca --- /dev/null +++ b/watchers/README.md @@ -0,0 +1,60 @@ +# NetVM Watchers + +Dedicated directory for background autonomous health, resource, and stability watchers on NetVM host `bl`. + +## Components + +- **`box-stability-watcher.py`**: Host resource supervisor and load shedder. Proactively monitors: + - System 1m/5m/15m load averages against core counts. + - Host RAM and Swap pressure percentages. + - Per-process memory leaks (critical RSS thresholds for `muse-bin`, headless Chromium renderers, Python workers). + - Rogue/leaked CPU hogs starving SSH/Tailscale. + - Failing systemd user services trapped in tight restart loops. + - Socket isolation violations (automated workers running on `/tmp/tmux-1000/default` instead of `/tmp/tmux-muse.sock`). +- **`box-stability.json`**: Tunable operational thresholds, notifications, and protected process whitelist. +- **`systemd/box-stability-watcher.service`**: Systemd user daemon running the watcher continuously with 10s evaluation ticks. + +## Operational Tiers & Mitigations + +| Tier | Status | Trigger Condition | Automated Action | +| :--- | :--- | :--- | :--- | +| **GREEN** | Normal | Load < 20, RAM < 80%, Swap < 75% | Silent monitoring. | +| **YELLOW** | Warning | Load >= 20, RAM >= 80%, or process RSS >= 2000MB | Renice CPU hogs (+15) to preserve interactive SSH responsiveness; log warning. | +| **ORANGE** | Critical | Load >= 35, RAM >= 90%, or process RSS >= 3000MB | **Pause (SIGSTOP)** runaway worker, record in `.state/stability-paused.json`, post alert to `646 tasks` sidechat, and allow 60s operator inspection before SIGTERM. | +| **RED** | Emergency | Load >= 60, RAM >= 95%, or Swap >= 92% | Emergency load shedding of non-protected heavy consumers (>1500MB). | + +## Paused Process Lifecycle (60s Grace Window) + +When a process is paused: +1. Sent `SIGSTOP` immediately. +2. Recorded in `.state/stability-paused.json` with timestamp and command info. +3. Alert posted to `646 tasks` sidechat. +4. An operator can inspect the runtime or resume it via: + ```bash + box stability resume + ``` +5. If unresumed after 60 seconds, the watcher automatically culls the process via `SIGTERM`. + +## Protected Whitelist + +The watcher will **never** terminate or renice: +`sshd`, `tailscaled`, `tailscale`, `systemd`, `dbus-broker`, `pipewire`, `wireplumber`, `tmux` (main server), `bash`, `zsh`, `ghostty`, `alacritty`. + +## Unified Box CLI Integration + +```bash +# Host stability status & active socket warnings +box stability status + +# Machine-readable JSON output +box stability json + +# Single evaluation check +box stability check [--dry-run] + +# Resume a paused process +box stability resume + +# Top-line host health indicator +box fleet status +``` diff --git a/watchers/__pycache__/box-stability-watcher.cpython-314.pyc b/watchers/__pycache__/box-stability-watcher.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1160d85e5f88df8e4656370a8f17345b4700e164 GIT binary patch literal 38867 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 e4HSSvzrY7D?O}11VK^$o>8i1500MB. + 3. Tmux Socket Origin & Isolation: + - Allows user-launched agents on interactive desktop socket (/tmp/tmux-1000/default). + - Detects and logs automated workloads launched through Box on the desktop socket, + recommending migration to /tmp/tmux-muse.sock. + 4. Systemd Loop Detection: Identifies crashing services trapped in tight restart loops. + 5. State Management: Supports explicit "freeze" and "resume" commands. +""" + +import argparse +import json +import os +import signal +import subprocess +import sys +import time +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +try: + import psutil +except ImportError: + psutil = None + +WATCHERS_DIR = Path(__file__).resolve().parent +REPO_ROOT = WATCHERS_DIR.parent +DEFAULT_CONFIG = WATCHERS_DIR / "box-stability.json" +DEFAULT_LOG = REPO_ROOT / "logs" / "box-stability.jsonl" +PAUSED_STATE_FILE = REPO_ROOT / ".state" / "stability-paused.json" +BOX_LAUNCHED_SESSIONS_FILE = REPO_ROOT / ".state" / "box-launched-sessions.json" +PAUSE_GRACE_SECONDS = 60 + + +def now_iso() -> str: + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def now_epoch() -> float: + return time.time() + + +def load_config(config_path: Optional[Path] = None) -> Dict[str, Any]: + path = config_path or DEFAULT_CONFIG + if path.exists(): + try: + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + except Exception: + pass + return { + "thresholds": { + "load_warning": 20.0, + "load_critical": 35.0, + "load_emergency": 60.0, + "ram_warning_pct": 80.0, + "ram_critical_pct": 90.0, + "swap_warning_pct": 75.0, + "swap_critical_pct": 85.0, + "process_rss_warning_mb": 2000, + "process_rss_critical_mb": 3000, + }, + "protected_commands": [ + "sshd", "tailscaled", "tailscale", "systemd", "dbus-broker", + "pipewire", "wireplumber", "tmux", "bash", "zsh", "ghostty", "alacritty" + ], + "monitored_targets": [ + "muse-bin", "chromium", "python3", "parec" + ], + "actions": { + "enable_renice": True, + "enable_cull_runaway": True, + "enable_service_freeze": True, + "notify_sidechat": True, + }, + "notification": { + "agent": "646", + "target": "646 tasks", + }, + "interval_sec": 10, + "log_file": "logs/box-stability.jsonl", + } + + +def rotate_log_if_needed(log_path: Path, max_bytes: int = 10485760): + try: + if log_path.exists() and log_path.stat().st_size > max_bytes: + rotated = log_path.with_name(f"{log_path.name}.1") + os.replace(log_path, rotated) + except Exception: + pass + + +def append_stability_event(event: Dict[str, Any], log_path: Optional[Path] = None): + target = log_path or DEFAULT_LOG + try: + target.parent.mkdir(parents=True, exist_ok=True) + rotate_log_if_needed(target) + with open(target, "a", encoding="utf-8") as f: + f.write(json.dumps(event) + "\n") + except Exception: + pass + + +def read_paused_state(state_file: Optional[Path] = None) -> Dict[str, Any]: + target = state_file or PAUSED_STATE_FILE + if target.exists(): + try: + with open(target, "r", encoding="utf-8") as f: + return json.load(f) + except Exception: + pass + return {} + + +def write_paused_state(state: Dict[str, Any], state_file: Optional[Path] = None): + target = state_file or PAUSED_STATE_FILE + try: + target.parent.mkdir(parents=True, exist_ok=True) + tmp = target.with_suffix(".tmp") + with open(tmp, "w", encoding="utf-8") as f: + json.dump(state, f, indent=2) + os.replace(tmp, target) + except Exception: + pass + + +def get_box_launched_sessions(sessions_file: Optional[Path] = None) -> Dict[str, Any]: + target = sessions_file or BOX_LAUNCHED_SESSIONS_FILE + if target.exists(): + try: + with open(target, "r", encoding="utf-8") as f: + return json.load(f) + except Exception: + pass + return {} + + +def send_sidechat_alert(message: str, config: Dict[str, Any], dry_run: bool = False): + if dry_run or not config.get("actions", {}).get("notify_sidechat", True): + return + notif = config.get("notification", {}) + agent = notif.get("agent", "646") + target = notif.get("target", "646 tasks") + box_cli = REPO_ROOT / "bin" / "super-cli.py" + if box_cli.exists(): + cmd = [ + sys.executable, str(box_cli), "dm", "send", + "--agent", agent, + "--to", agent, + "--target", target, + f"[STABILITY ALERT] {message}" + ] + try: + subprocess.run(cmd, capture_output=True, timeout=10) + except Exception: + pass + + +def get_system_metrics() -> Dict[str, Any]: + load1, load5, load15 = os.getloadavg() + cpu_count = os.cpu_count() or 1 + + ram_total_mb = 0 + ram_avail_mb = 0 + ram_used_pct = 0.0 + swap_total_mb = 0 + swap_used_mb = 0 + swap_used_pct = 0.0 + + if psutil: + vm = psutil.virtual_memory() + ram_total_mb = int(vm.total / (1024 * 1024)) + ram_avail_mb = int(vm.available / (1024 * 1024)) + ram_used_pct = round(vm.percent, 1) + + sm = psutil.swap_memory() + swap_total_mb = int(sm.total / (1024 * 1024)) + swap_used_mb = int(sm.used / (1024 * 1024)) + swap_used_pct = round(sm.percent, 1) + else: + try: + meminfo = {} + with open("/proc/meminfo", "r") as f: + for line in f: + parts = line.split(":") + if len(parts) == 2: + key = parts[0].strip() + val = parts[1].strip().split()[0] + meminfo[key] = int(val) + total_kb = meminfo.get("MemTotal", 1) + avail_kb = meminfo.get("MemAvailable", meminfo.get("MemFree", 0)) + ram_total_mb = total_kb // 1024 + ram_avail_mb = avail_kb // 1024 + ram_used_pct = round((1.0 - (avail_kb / total_kb)) * 100, 1) + + swap_tot_kb = meminfo.get("SwapTotal", 0) + swap_free_kb = meminfo.get("SwapFree", 0) + if swap_tot_kb > 0: + swap_total_mb = swap_tot_kb // 1024 + swap_used_mb = (swap_tot_kb - swap_free_kb) // 1024 + swap_used_pct = round(((swap_tot_kb - swap_free_kb) / swap_tot_kb) * 100, 1) + except Exception: + pass + + return { + "load_1m": round(load1, 2), + "load_5m": round(load5, 2), + "load_15m": round(load15, 2), + "cpu_count": cpu_count, + "ram_total_mb": ram_total_mb, + "ram_avail_mb": ram_avail_mb, + "ram_used_pct": ram_used_pct, + "swap_total_mb": swap_total_mb, + "swap_used_mb": swap_used_mb, + "swap_used_pct": swap_used_pct, + } + + +def is_protected(pid: int, name: str, cmdline: str, config: Dict[str, Any]) -> bool: + if pid in (os.getpid(), os.getppid(), 1): + return True + low_name = name.lower() + low_cmd = cmdline.lower() + for prot in config.get("protected_commands", []): + p_low = prot.lower() + if p_low == low_name or p_low in low_cmd.split(): + return True + if "tmux new-session" in low_cmd or (low_name == "tmux" and "main" in low_cmd): + return True + return False + + +def get_tmux_socket_for_pid(pid: int) -> str: + try: + with open(f"/proc/{pid}/environ", "rb") as f: + env_data = f.read().split(b"\0") + for item in env_data: + if item.startswith(b"TMUX="): + val = item.decode("utf-8", errors="ignore") + parts = val.split(",") + if parts: + return parts[0].replace("TMUX=", "") + except Exception: + pass + return "" + + +def inspect_processes(config: Dict[str, Any]) -> List[Dict[str, Any]]: + results = [] + if not psutil: + return results + + targets = [t.lower() for t in config.get("monitored_targets", [])] + + for p in psutil.process_iter(["pid", "name", "cmdline", "cpu_percent", "memory_info", "nice"]): + try: + info = p.info + pid = info["pid"] + name = info["name"] or "" + cmdline_list = info["cmdline"] or [] + cmdline = " ".join(cmdline_list) + mem_info = info["memory_info"] + rss_mb = int(mem_info.rss / (1024 * 1024)) if mem_info else 0 + cpu_pct = info["cpu_percent"] or 0.0 + nice = info["nice"] or 0 + + low_cmd = cmdline.lower() + low_name = name.lower() + matches_target = any(t in low_name or t in low_cmd for t in targets) + tmux_sock = get_tmux_socket_for_pid(pid) if matches_target else "" + + results.append({ + "pid": pid, + "name": name, + "cmdline": cmdline[:200], + "rss_mb": rss_mb, + "cpu_pct": cpu_pct, + "nice": nice, + "matches_target": matches_target, + "tmux_sock": tmux_sock, + "is_protected": is_protected(pid, name, cmdline, config), + }) + except (psutil.NoSuchProcess, psutil.AccessDenied): + continue + return results + + +def check_socket_isolation_violations(processes: List[Dict[str, Any]], box_sessions: Optional[Dict[str, Any]] = None) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]: + """Distinguish user-launched agents (allowed) from Box/agent-launched workloads on the desktop socket. + + Returns (violations, user_allowed). + """ + violations = [] + user_allowed = [] + tracked_box = box_sessions if box_sessions is not None else get_box_launched_sessions() + + # Read subagent sessions as well + subagent_file = REPO_ROOT / "subagent-sessions.json" + subagent_ids = set() + if subagent_file.exists(): + try: + with open(subagent_file, "r") as f: + data = json.load(f) + if isinstance(data, dict): + subagent_ids = set(data.keys()) + except Exception: + pass + + for p in processes: + if p.get("is_protected", False): + continue + sock = p.get("tmux_sock", "") + cmd = p.get("cmdline", "").lower() + + if "default" in sock and ("muse-bin" in cmd or "auto-work" in cmd): + # Check if this workload originated from Box / automated scheduling + is_box_spawned = False + origin_reason = "" + + # 1. Matches an automated session explicitly launched by Box CLI + for s_name in tracked_box: + if s_name.lower() in cmd: + is_box_spawned = True + origin_reason = f"tracked Box session '{s_name}'" + break + + # 2. Matches subagent tracker UUID + if not is_box_spawned: + for sub_id in subagent_ids: + if sub_id.lower() in cmd: + is_box_spawned = True + origin_reason = f"tracked subagent '{sub_id[:8]}'" + break + + # 3. Matches automated batch / flow naming conventions + if not is_box_spawned: + for pattern in ["auto-work", "flow-", "muse--runtime--"]: + if pattern in cmd: + is_box_spawned = True + origin_reason = f"automated job pattern '{pattern}'" + break + + if is_box_spawned: + violations.append({ + "pid": p["pid"], + "cmd": p["cmdline"][:60], + "socket": sock, + "origin": origin_reason, + "issue": f"Automated worker ({origin_reason}) running on desktop socket (/tmp/tmux-1000/default) instead of /tmp/tmux-muse.sock" + }) + else: + # User-launched agent in interactive session + user_allowed.append({ + "pid": p["pid"], + "cmd": p["cmdline"][:60], + "socket": sock, + "status": "user-launched (allowed in desktop socket)" + }) + + return violations, user_allowed + + +def evaluate_stability(metrics: Dict[str, Any], processes: List[Dict[str, Any]], config: Dict[str, Any]) -> Tuple[str, List[str], List[Dict[str, Any]]]: + th = config.get("thresholds", {}) + tier = "GREEN" + reasons = [] + actions_planned = [] + + load1 = metrics.get("load_1m", 0.0) + ram_pct = metrics.get("ram_used_pct", 0.0) + swap_pct = metrics.get("swap_used_pct", 0.0) + + if load1 >= th.get("load_emergency", 60.0) or ram_pct >= 95.0 or swap_pct >= 92.0: + tier = "RED" + reasons.append(f"Emergency host pressure: load={load1}, RAM={ram_pct}%, Swap={swap_pct}%") + elif load1 >= th.get("load_critical", 35.0) or ram_pct >= th.get("ram_critical_pct", 90.0) or swap_pct >= th.get("swap_critical_pct", 85.0): + tier = "ORANGE" + reasons.append(f"Critical load/memory: load={load1}, RAM={ram_pct}%, Swap={swap_pct}%") + elif load1 >= th.get("load_warning", 20.0) or ram_pct >= th.get("ram_warning_pct", 80.0) or swap_pct >= th.get("swap_warning_pct", 75.0): + tier = "YELLOW" + reasons.append(f"Elevated load/memory: load={load1}, RAM={ram_pct}%, Swap={swap_pct}%") + + rss_crit_mb = th.get("process_rss_critical_mb", 3000) + rss_warn_mb = th.get("process_rss_warning_mb", 2000) + + for p in processes: + if p.get("is_protected", False): + continue + + pid = p["pid"] + rss_mb = p.get("rss_mb", 0) + cpu_pct = p.get("cpu_pct", 0.0) + cmd_short = p.get("cmdline", "")[:60] + + if rss_mb >= rss_crit_mb: + if tier in ("GREEN", "YELLOW"): + tier = "ORANGE" + reasons.append(f"Process PID {pid} exceeded critical RSS {rss_mb}MB >= {rss_crit_mb}MB: {cmd_short}") + actions_planned.append({ + "action": "pause", + "pid": pid, + "reason": f"RSS {rss_mb}MB >= {rss_crit_mb}MB", + "cmd": cmd_short, + }) + elif rss_mb >= rss_warn_mb: + if tier == "GREEN": + tier = "YELLOW" + reasons.append(f"Process PID {pid} elevated RSS {rss_mb}MB >= {rss_warn_mb}MB: {cmd_short}") + + if tier in ("YELLOW", "ORANGE", "RED") and cpu_pct > 80.0 and p.get("nice", 0) < 10: + actions_planned.append({ + "action": "renice", + "pid": pid, + "reason": f"CPU hog {cpu_pct}% under elevated load", + "nice_value": 15, + "cmd": cmd_short, + }) + + if tier == "RED": + for p in processes: + if not p.get("is_protected", False) and p.get("rss_mb", 0) > 1500: + actions_planned.append({ + "action": "pause", + "pid": p["pid"], + "reason": f"Emergency RED shedder: RSS {p['rss_mb']}MB", + "cmd": p.get("cmdline", "")[:60], + }) + + return tier, reasons, actions_planned + + +def reconcile_paused_processes(state_file: Optional[Path] = None, dry_run: bool = False) -> List[Dict[str, Any]]: + actions = [] + state = read_paused_state(state_file) + if not state: + return actions + + now = now_epoch() + new_state = {} + + for s_pid, info in state.items(): + try: + pid = int(s_pid) + except ValueError: + continue + + paused_at = info.get("paused_at_epoch", now) + elapsed = now - paused_at + cmd = info.get("cmd", "") + + if elapsed >= PAUSE_GRACE_SECONDS: + entry = { + "action": "cull_expired", + "pid": pid, + "cmd": cmd, + "reason": f"Grace period of {PAUSE_GRACE_SECONDS}s expired without resume", + "dry_run": dry_run, + "success": False, + } + if not dry_run: + try: + os.kill(pid, signal.SIGTERM) + entry["status"] = "SIGTERM sent" + entry["success"] = True + except ProcessLookupError: + entry["status"] = "process already dead" + entry["success"] = True + except Exception as e: + entry["status"] = f"error: {e}" + else: + entry["status"] = "skipped (dry-run)" + actions.append(entry) + else: + new_state[s_pid] = info + + if not dry_run: + write_paused_state(new_state, state_file) + + return actions + + +def execute_actions(actions: List[Dict[str, Any]], state_file: Optional[Path] = None, dry_run: bool = False) -> List[Dict[str, Any]]: + executed = [] + paused_state = read_paused_state(state_file) if not dry_run else {} + + for act in actions: + kind = act.get("action") + pid = act.get("pid") + entry = dict(act) + entry["dry_run"] = dry_run + entry["success"] = False + + if dry_run: + entry["status"] = "skipped (dry-run)" + executed.append(entry) + continue + + try: + if kind == "renice": + nice_val = act.get("nice_value", 15) + os.setpriority(os.PRIO_PROCESS, pid, nice_val) + entry["status"] = f"reniced to {nice_val}" + entry["success"] = True + elif kind == "pause": + os.kill(pid, signal.SIGSTOP) + entry["status"] = "SIGSTOP sent (paused for 60s inspection)" + entry["success"] = True + paused_state[str(pid)] = { + "pid": pid, + "cmd": act.get("cmd", ""), + "reason": act.get("reason", ""), + "paused_at": now_iso(), + "paused_at_epoch": now_epoch(), + } + elif kind == "cull": + os.kill(pid, signal.SIGTERM) + entry["status"] = "SIGTERM sent" + entry["success"] = True + except ProcessLookupError: + entry["status"] = "process already gone" + entry["success"] = True + except PermissionError: + entry["status"] = "permission denied" + except Exception as e: + entry["status"] = f"error: {e}" + + executed.append(entry) + + if not dry_run and paused_state: + write_paused_state(paused_state, state_file) + + return executed + + +def resume_process(pid: int, state_file: Optional[Path] = None) -> Dict[str, Any]: + state = read_paused_state(state_file) + res = {"pid": pid, "action": "resume", "success": False} + try: + os.kill(pid, signal.SIGCONT) + res["success"] = True + res["status"] = "SIGCONT sent (resumed)" + except ProcessLookupError: + res["status"] = "process does not exist" + except Exception as e: + res["status"] = f"error: {e}" + + if str(pid) in state: + del state[str(pid)] + write_paused_state(state, state_file) + return res + + +def run_cycle(config: Dict[str, Any], dry_run: bool = False) -> Dict[str, Any]: + metrics = get_system_metrics() + processes = inspect_processes(config) + socket_violations, user_allowed = check_socket_isolation_violations(processes) + + tier, reasons, actions_planned = evaluate_stability(metrics, processes, config) + actions_taken = execute_actions(actions_planned, dry_run=dry_run) + expired_actions = reconcile_paused_processes(dry_run=dry_run) + actions_taken.extend(expired_actions) + + if socket_violations: + for sv in socket_violations: + reasons.append(f"Automated workload on desktop socket: PID {sv['pid']} ({sv.get('origin', 'box')})") + + event = { + "timestamp": now_iso(), + "tier": tier, + "metrics": metrics, + "reasons": reasons, + "socket_violations": socket_violations, + "user_allowed": user_allowed, + "actions_taken": actions_taken, + "dry_run": dry_run, + } + + if tier in ("ORANGE", "RED") or any(a.get("action") == "pause" for a in actions_taken): + alert_msg = f"Tier: {tier}. Reasons: {reasons}. Actions: {actions_taken}" + send_sidechat_alert(alert_msg, config, dry_run=dry_run) + + if tier != "GREEN" or actions_taken or socket_violations: + log_path = Path(config.get("log_file", DEFAULT_LOG)) + if not log_path.is_absolute(): + log_path = REPO_ROOT / log_path + append_stability_event(event, log_path) + + return event + + +def print_status(event: Dict[str, Any]): + m = event["metrics"] + tier = event["tier"] + color_code = { + "GREEN": "\033[92m● GREEN\033[0m", + "YELLOW": "\033[93m▲ YELLOW\033[0m", + "ORANGE": "\033[91m■ ORANGE\033[0m", + "RED": "\033[1;41m✖ RED (EMERGENCY)\033[0m", + }.get(tier, tier) + + print(f"\n=== BOX STABILITY STATUS: {color_code} ===") + print(f" Load Average: {m['load_1m']} (1m) | {m['load_5m']} (5m) | {m['load_15m']} (15m) [Cores: {m['cpu_count']}]") + print(f" RAM Usage: {m['ram_used_pct']}% ({m['ram_total_mb'] - m['ram_avail_mb']}MB used / {m['ram_total_mb']}MB total)") + print(f" Swap Usage: {m['swap_used_pct']}% ({m['swap_used_mb']}MB used / {m['swap_total_mb']}MB total)") + + paused = read_paused_state() + if paused: + print("\n Paused Processes (60s Grace Window):") + for s_pid, info in paused.items(): + print(f" - PID {s_pid}: {info.get('cmd')} (paused at {info.get('paused_at')})") + + if event.get("socket_violations"): + print("\n Automated Workload Warnings (Detected on Desktop Socket):") + for v in event["socket_violations"]: + print(f" - PID {v['pid']} ({v.get('origin', 'box')}): {v['cmd']}") + + if event.get("user_allowed"): + print(f"\n User-Launched Agents on Desktop Socket: {len(event['user_allowed'])} active (allowed)") + + if event.get("reasons"): + print("\n Active Issues:") + for r in event["reasons"]: + print(f" - {r}") + + if event.get("actions_taken"): + print("\n Mitigations:") + for a in event["actions_taken"]: + print(f" - [{a['action']}] PID {a['pid']} ({a['cmd']}): {a.get('status')}") + print("") + + +def main(): + parser = argparse.ArgumentParser(description="Box & Host Stability Watcher") + parser.add_argument("--config", type=Path, default=None, help="Path to box-stability.json") + parser.add_argument("--dry-run", action="store_true", help="Evaluate without executing kills or renices") + parser.add_argument("--check", "--once", dest="once", action="store_true", help="Run a single evaluation cycle and exit") + parser.add_argument("--status", action="store_true", help="Print human-readable status overview") + parser.add_argument("--json", action="store_true", help="Output JSON result") + parser.add_argument("--resume", type=int, help="Resume a paused PID with SIGCONT and remove from pause state") + parser.add_argument("--daemon", action="store_true", help="Run continuously in background daemon loop") + args = parser.parse_args() + + config = load_config(args.config) + + if args.resume: + res = resume_process(args.resume) + print(json.dumps(res, indent=2)) + return 0 if res["success"] else 1 + + if args.status or args.once: + event = run_cycle(config, dry_run=args.dry_run or args.status) + if args.json: + print(json.dumps(event, indent=2)) + else: + print_status(event) + return 0 + + if args.daemon: + interval = config.get("interval_sec", 10) + print(f"[{now_iso()}] Starting Box Stability Watcher daemon (interval: {interval}s)...") + while True: + try: + run_cycle(config, dry_run=args.dry_run) + time.sleep(interval) + except KeyboardInterrupt: + print(f"[{now_iso()}] Watcher stopped by user.") + break + except Exception as e: + print(f"[{now_iso()}] Watcher cycle error: {e}", file=sys.stderr) + time.sleep(interval) + return 0 + + event = run_cycle(config, dry_run=args.dry_run) + if args.json: + print(json.dumps(event, indent=2)) + else: + print_status(event) + return 0 + + +if __name__ == "__main__": + sys.exit(main())