botWebWars/launcher_common.py

353 lines
11 KiB
Python

"""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()}/<agent_name>.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)