"""Common process management and launcher utilities for botWebWars agents.""" import argparse import os import re import signal import subprocess import sys import threading import time from pathlib import Path from typing import List, Optional, Tuple # ANSI color codes for pretty terminal logging RESET = "\033[0m" BOLD = "\033[1m" GREEN = "\033[1;32m" YELLOW = "\033[1;33m" RED = "\033[1;31m" CYAN = "\033[1;36m" MAGENTA = "\033[1;35m" BLUE = "\033[1;34m" CONSOLE_COLORS = [ "\033[1;34m", # Blue "\033[1;35m", # Magenta "\033[1;36m", # Cyan "\033[1;32m", # Green "\033[1;33m", # Yellow "\033[1;31m", # Red "\033[1;94m", # Light Blue "\033[1;95m", # Light Magenta "\033[1;96m", # Light Cyan "\033[1;92m", # Light Green ] DEFAULT_BOT_COLORS = [ "#3b82f6", # Blue "#8b5cf6", # Purple "#ec4899", # Pink "#06b6d4", # Cyan "#f59e0b", # Amber "#10b981", # Emerald "#6366f1", # Indigo "#f43f5e", # Rose "#14b8a6", # Teal "#e11d48", # Crimson ] DEFAULT_TROLL_COLORS = [ "#15803d", # Forest Green "#166534", # Dark Green "#047857", # Emerald / Swamp "#4d7c0f", # Olive Lime "#3f6212", # Deep Olive "#b45309", # Dark Amber / Brown "#7c2d12", # Rust / Bloodwood "#581c87", # Dark Purple "#0f766e", # Deep Teal "#1e293b", # Slate Dark ] def sanitize_model_name(model: str) -> str: """Sanitizes model name by replacing non-alphanumeric characters with underscores. Example: 'gemma4:e4b' -> 'gemma4_e4b', 'llama3.2:1b' -> 'llama3_2_1b' """ cleaned = re.sub(r"[^a-zA-Z0-9]+", "_", model).strip("_") return cleaned or "model" def make_agent_name(folder: str, model: str, index: int, prefix: Optional[str] = None) -> str: """Generates the standardized agent name in the format: {folder}_{model}_{index}. Example: 'botagent_ai_gemma4_e4b_3' """ base_prefix = prefix.strip("_") if prefix else folder clean_model = sanitize_model_name(model) return f"{base_prefix}_{clean_model}_{index}" def find_python_executable(agent_dir: Path, custom_python: Optional[str] = None) -> str: """Locates the appropriate Python binary: 1. Custom explicit python path if provided. 2. Active virtual environment if invoked inside one. 3. Agent directory local virtual environment (e.g. {agent_dir}/venv/bin/python). 4. sys.executable or system python3. """ if custom_python: return custom_python # If the user explicitly activated an environment outside if getattr(sys, "base_prefix", None) != sys.prefix: return sys.executable # Check local venv inside the agent's folder local_venv = agent_dir / "venv" / "bin" / "python" if local_venv.is_file() and os.access(local_venv, os.X_OK): return str(local_venv) # Fallback to current sys.executable return sys.executable def stream_output( agent_name: str, color_code: str, proc: subprocess.Popen, log_file: Optional[Path], ) -> None: """Streams child process stdout/stderr line-by-line to console and log file.""" log_fp = None if log_file: try: log_file.parent.mkdir(parents=True, exist_ok=True) log_fp = open(log_file, "a", encoding="utf-8") except Exception as e: print(f"{RED}⚠️ Failed to open log file {log_file}: {e}{RESET}", flush=True) last_lobby_time = 0.0 try: if proc.stdout: for raw_line in iter(proc.stdout.readline, ""): if not raw_line: break if log_fp: log_fp.write(raw_line) log_fp.flush() line = raw_line.rstrip("\r\n") if not line: continue # Throttle lobby polling notifications to avoid console flooding if "[LOBBY]" in line: now = time.time() if now - last_lobby_time < 8.0: continue last_lobby_time = now print(f"{color_code}[{agent_name}]{RESET} {line}", flush=True) except Exception: pass finally: if log_fp: try: log_fp.close() except Exception: pass def stop_all_processes(procs: List[Tuple[str, subprocess.Popen]]) -> None: """Gracefully terminates all child processes by sending SIGINT first.""" if not procs: return running_procs = [(name, p) for name, p in procs if p.poll() is None] if not running_procs: return print(f"\n{YELLOW}🛑 Stopping {len(running_procs)} agent session(s) gracefully...{RESET}", flush=True) # 1. Send SIGINT so bot.py catches KeyboardInterrupt and cleanly deregisters from the board for name, proc in running_procs: if proc.poll() is None: try: proc.send_signal(signal.SIGINT) except (ProcessLookupError, OSError): pass # 2. Give processes up to 4 seconds to deregister and exit cleanly deadline = time.time() + 4.0 for name, proc in running_procs: remaining = max(0.1, deadline - time.time()) try: proc.wait(timeout=remaining) except subprocess.TimeoutExpired: pass # 3. Terminate/kill any processes that hung for name, proc in running_procs: if proc.poll() is None: print(f"{RED}⚠️ Agent {name} did not exit in time; terminating...{RESET}", flush=True) try: proc.terminate() proc.wait(timeout=1.0) except Exception: try: proc.kill() except Exception: pass print(f"{GREEN}✅ All agent sessions stopped cleanly.{RESET}\n", flush=True) def run_agent_launcher( agent_type: str, folder: str, palette: List[str], args: argparse.Namespace, extra_args_builder=None, ) -> None: """Generic orchestrator for launching bot and troll agent pools.""" root_dir = Path(__file__).resolve().parent agent_dir = root_dir / folder if not agent_dir.is_dir(): print(f"{RED}❌ Error: Agent directory '{agent_dir}' not found.{RESET}", file=sys.stderr) sys.exit(1) entry_file = agent_dir / "bot.py" if not entry_file.is_file(): print(f"{RED}❌ Error: Entrypoint '{entry_file}' not found.{RESET}", file=sys.stderr) sys.exit(1) python_bin = find_python_executable(agent_dir, args.python) count = max(1, args.count) start_index = max(1, args.start_index) prefix = getattr(args, "prefix", None) # Prepare list of commands and configurations agent_configs = [] for offset in range(count): idx = start_index + offset name = make_agent_name(folder=folder, model=args.model, index=idx, prefix=prefix) color = args.color if args.color else palette[offset % len(palette)] console_color = CONSOLE_COLORS[offset % len(CONSOLE_COLORS)] cmd = [ python_bin, "bot.py", "--name", name, "--color", color, "-s", str(args.strength), "-H", str(args.health), "--ollama-url", args.ollama_url, "--ollama-model", args.model, "-u", args.server_url, ] if extra_args_builder: extra = extra_args_builder(args) if extra: cmd.extend(extra) log_file = None if not args.no_logs and args.log_dir: log_file = Path(args.log_dir).resolve() / f"{name}.log" agent_configs.append({ "index": idx, "name": name, "color": color, "console_color": console_color, "cmd": cmd, "log_file": log_file, }) # Dry-run display mode if args.dry_run: print(f"\n{CYAN}{'=' * 68}{RESET}") print(f"{BOLD}🔍 DRY RUN: {count} {agent_type}(s) [{folder}]{RESET}") print(f"{CYAN}{'=' * 68}{RESET}") print(f"Model: {BOLD}{args.model}{RESET}") print(f"Ollama URL: {args.ollama_url}") print(f"Server URL: {args.server_url}") print(f"Strength: {args.strength} | Health: {args.health}") print(f"Interpreter: {python_bin}") print(f"Working Dir: {agent_dir}") if not args.no_logs and args.log_dir: print(f"Log Dir: {Path(args.log_dir).resolve()}") print(f"{CYAN}{'-' * 68}{RESET}") for item in agent_configs: cmd_str = " ".join(item["cmd"]) print(f"[{item['index']}] Name: {BOLD}{item['name']}{RESET}") print(f" Color: {item['color']}") print(f" Cmd: {cmd_str}\n") print(f"{CYAN}{'=' * 68}{RESET}\n") return # Normal execution print(f"\n{CYAN}{'=' * 68}{RESET}") print(f"{BOLD}🚀 Launching {count} {agent_type}(s) [{folder}]{RESET}") print(f"Model: {BOLD}{args.model}{RESET}") print(f"Ollama URL: {args.ollama_url}") print(f"Server URL: {args.server_url}") print(f"Strength: {args.strength} | Health: {args.health}") print(f"Interpreter: {python_bin}") if not args.no_logs and args.log_dir: print(f"Logs: {Path(args.log_dir).resolve()}/.log") print(f"{CYAN}{'=' * 68}{RESET}") env = os.environ.copy() env["PYTHONUNBUFFERED"] = "1" env["BOT_SERVER_URL"] = args.server_url if "troll" in folder.lower(): env["TROLL_SERVER_URL"] = args.server_url procs: List[Tuple[str, subprocess.Popen]] = [] threads: List[threading.Thread] = [] interrupted = threading.Event() def handle_signal(signum, frame): interrupted.set() # Register signal traps old_sigint = signal.signal(signal.SIGINT, handle_signal) old_sigterm = signal.signal(signal.SIGTERM, handle_signal) try: for item in agent_configs: print(f"✨ Spawning {item['console_color']}{item['name']}{RESET} ({item['color']})...") proc = subprocess.Popen( item["cmd"], cwd=str(agent_dir), env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1, ) procs.append((item["name"], proc)) t = threading.Thread( target=stream_output, args=(item["name"], item["console_color"], proc, item["log_file"]), daemon=True, ) t.start() threads.append(t) time.sleep(0.15) # Slight stagger for clean initial connections print(f"{CYAN}{'=' * 68}{RESET}") print(f"{BOLD}💡 All {len(procs)} agent(s) spawned. Press Ctrl+C at any time to stop.{RESET}") print(f"{CYAN}{'=' * 68}{RESET}\n") # Monitor loop while not interrupted.is_set(): # Check if all processes have exited naturally if all(p.poll() is not None for _, p in procs): break interrupted.wait(timeout=0.5) except KeyboardInterrupt: interrupted.set() finally: stop_all_processes(procs) signal.signal(signal.SIGINT, old_sigint) signal.signal(signal.SIGTERM, old_sigterm)