#!/usr/bin/env python3 """ 🦊 Foxy Dev Team — Auto-Pilot Daemon v2.1 ========================================== Architecture : Python daemon + openclaw sessions spawn --mode run - Surveille project_state.json via polling (30s) - Lance chaque agent au bon moment via openclaw sessions spawn - Notification Telegram à chaque étape - Zéro intervention humaine requise Auteur : Foxy Dev Team Usage : python3 foxy-autopilot.py Service: systemctl --user start foxy-autopilot Changelog v2.1: - Fix: datetime.utcnow() → datetime.now(UTC) (DeprecationWarning) - Fix: Probe automatique de la syntaxe openclaw au démarrage - Fix: Double-logging supprimé (un seul handler StreamHandler) - Fix: spawn_agent adapté dynamiquement à la syntaxe openclaw réelle """ import json import os import subprocess import sys import time import signal import logging import urllib.request import urllib.parse from datetime import datetime, timezone, timedelta from pathlib import Path # Alias UTC propre (remplace utcnow()) UTC = timezone.utc def utcnow() -> datetime: """Retourne l'heure UTC actuelle (timezone-aware).""" return datetime.now(UTC) def utcnow_iso() -> str: """Retourne l'heure UTC actuelle au format ISO 8601 avec Z.""" return utcnow().strftime("%Y-%m-%dT%H:%M:%SZ") # ─── CONFIG ──────────────────────────────────────────────────────────────────── WORKSPACE = Path("/home/openclaw/.openclaw/workspace") LOG_FILE = Path("/home/openclaw/.openclaw/logs/foxy-autopilot.log") POLL_INTERVAL = 30 # secondes entre chaque vérification SPAWN_TIMEOUT = 7200 # 2h max par agent TELEGRAM_BOT = "8686313703:AAEGUunkJWbJx7njX_NUrW9HcyrZqXzA3KQ" TELEGRAM_CHAT = "8379645618" # Mapping agent → label openclaw (doit correspondre à openclaw agents list) AGENT_LABELS = { "Foxy-Conductor": "foxy-conductor", "Foxy-Architect": "foxy-architect", "Foxy-Dev": "foxy-dev", "Foxy-UIUX": "foxy-uiux", "Foxy-QA": "foxy-qa", "Foxy-Admin": "foxy-admin", } # Transitions de statut → quel agent appeler STATUS_TRANSITIONS = { "AWAITING_CONDUCTOR": ("Foxy-Conductor", "CONDUCTOR_RUNNING"), "AWAITING_ARCHITECT": ("Foxy-Architect", "ARCHITECT_RUNNING"), "AWAITING_DEV": ("Foxy-Dev", "DEV_RUNNING"), "AWAITING_UIUX": ("Foxy-UIUX", "UIUX_RUNNING"), "AWAITING_QA": ("Foxy-QA", "QA_RUNNING"), "AWAITING_DEPLOY": ("Foxy-Admin", "DEPLOY_RUNNING"), } RUNNING_STATUSES = { "CONDUCTOR_RUNNING", "ARCHITECT_RUNNING", "DEV_RUNNING", "UIUX_RUNNING", "QA_RUNNING", "DEPLOY_RUNNING", "COMPLETED", "FAILED" } # ─── LOGGING ─────────────────────────────────────────────────────────────────── LOG_FILE.parent.mkdir(parents=True, exist_ok=True) # FIX: éviter le double-logging observé dans les logs # (se produit quand le root logger a déjà des handlers, ex: relance du daemon) log = logging.getLogger("foxy-autopilot") if not log.handlers: log.setLevel(logging.INFO) fmt = logging.Formatter( "[%(asctime)s] %(levelname)s %(message)s", datefmt="%Y-%m-%dT%H:%M:%SZ" ) fh = logging.FileHandler(LOG_FILE) fh.setFormatter(fmt) sh = logging.StreamHandler(sys.stdout) sh.setFormatter(fmt) log.addHandler(fh) log.addHandler(sh) log.propagate = False # ne pas remonter au root logger # ─── SIGNAL HANDLING ─────────────────────────────────────────────────────────── _running = True def handle_signal(sig, frame): global _running log.info("🛑 Signal reçu — arrêt propre du daemon...") _running = False signal.signal(signal.SIGTERM, handle_signal) signal.signal(signal.SIGINT, handle_signal) # ─── TELEGRAM ────────────────────────────────────────────────────────────────── def notify(msg: str): """Envoie une notification Telegram (non-bloquant, échec silencieux).""" try: url = f"https://api.telegram.org/bot{TELEGRAM_BOT}/sendMessage" data = urllib.parse.urlencode({ "chat_id": TELEGRAM_CHAT, "text": msg, "parse_mode": "HTML" }).encode() req = urllib.request.Request(url, data=data, method="POST") with urllib.request.urlopen(req, timeout=5): pass except Exception as e: log.warning(f"Telegram error (ignoré): {e}") # ─── PROBE SYNTAXE OPENCLAW ──────────────────────────────────────────────────── # Résultat du probe stocké au démarrage _OPENCLAW_SPAWN_CMD: list[str] | None = None def _run_help(args: list[str]) -> str: """Lance une commande --help et retourne stdout+stderr combinés.""" try: r = subprocess.run( args, capture_output=True, text=True, timeout=10, env={**os.environ, "HOME": "/home/openclaw"} ) return (r.stdout + r.stderr).lower() except Exception: return "" def probe_openclaw_syntax() -> list[str] | None: """ Détecte la syntaxe réelle de `openclaw sessions spawn` au démarrage. Retourne le template de commande (sans --task) ou None si openclaw absent. Stratégies testées dans l'ordre : 1. openclaw sessions spawn --help → cherche --label, --agent, --task 2. openclaw session spawn --help (alias singulier) 3. openclaw run --help (syntaxe alternative courante) 4. openclaw spawn --help (syntaxe flat) """ # Vérifier que openclaw est dans le PATH which = subprocess.run(["which", "openclaw"], capture_output=True, text=True) if which.returncode != 0: log.error("❌ 'openclaw' introuvable dans PATH. Vérifie l'installation.") return None log.info(f"✅ openclaw trouvé : {which.stdout.strip()}") # Liste de candidats (commande_help, commande_spawn_template) candidates = [ # Syntaxe v2.0 originale supposée ( ["openclaw", "sessions", "spawn", "--help"], ["openclaw", "sessions", "spawn", "--label", "{label}", "--agent", "{agent}", "--task", "{task}", "--mode", "run", "--runtime", "subagent"], ["--label", "--agent", "--task"] ), # Syntaxe sans --label ni --runtime ( ["openclaw", "sessions", "spawn", "--help"], ["openclaw", "sessions", "spawn", "--agent", "{agent}", "--task", "{task}", "--mode", "run"], ["--agent", "--task"] ), # Alias singulier ( ["openclaw", "session", "spawn", "--help"], ["openclaw", "session", "spawn", "--agent", "{agent}", "--task", "{task}"], ["--agent", "--task"] ), # Syntaxe 'run' directe ( ["openclaw", "run", "--help"], ["openclaw", "run", "--agent", "{agent}", "--task", "{task}"], ["--agent", "--task"] ), # Syntaxe flat ( ["openclaw", "spawn", "--help"], ["openclaw", "spawn", "--agent", "{agent}", "--task", "{task}"], ["--agent", "--task"] ), ] for help_cmd, spawn_template, required_flags in candidates: output = _run_help(help_cmd) if not output: continue if all(flag.lstrip("-") in output for flag in required_flags): log.info(f"✅ Syntaxe openclaw détectée : {' '.join(spawn_template[:4])}...") return spawn_template # Aucune syntaxe connue trouvée — logguer le help brut pour debug log.warning("⚠️ Aucune syntaxe connue détectée. Affichage du help brut :") raw = _run_help(["openclaw", "--help"]) for line in raw.splitlines()[:30]: log.warning(f" {line}") # Fallback : retourner la syntaxe sans --label (la plus probable) fallback = ["openclaw", "sessions", "spawn", "--agent", "{agent}", "--task", "{task}", "--mode", "run"] log.warning(f"⚠️ Fallback : {' '.join(fallback[:5])}...") return fallback def build_spawn_cmd(template: list[str], agent_label: str, task_msg: str, spawn_label: str) -> list[str]: """ Construit la commande spawn finale à partir du template détecté. Remplace {label}, {agent}, {task} par les valeurs réelles. """ return [ t.replace("{label}", spawn_label) .replace("{agent}", agent_label) .replace("{task}", task_msg) for t in template ] # ─── STATE HELPERS ───────────────────────────────────────────────────────────── def load_state(state_file: Path) -> dict | None: try: with open(state_file) as f: return json.load(f) except json.JSONDecodeError as e: log.warning(f"JSON invalide dans {state_file}: {e}") return None except Exception as e: log.warning(f"Erreur lecture {state_file}: {e}") return None def save_state(state_file: Path, state: dict): backup = state_file.with_suffix(".json.bak") try: if state_file.exists(): state_file.rename(backup) with open(state_file, "w") as f: json.dump(state, f, indent=2, ensure_ascii=False) log.info(f"💾 State sauvegardé: {state_file.parent.name}") except Exception as e: log.error(f"Erreur sauvegarde {state_file}: {e}") if backup.exists(): backup.rename(state_file) def add_audit(state: dict, action: str, agent: str, details: str = ""): state.setdefault("audit_log", []).append({ "timestamp": utcnow_iso(), "action": action, "agent": agent, "details": details, "source": "foxy-autopilot" }) def mark_status(state_file: Path, state: dict, new_status: str, agent: str): old = state.get("status", "?") state["status"] = new_status state["updated_at"] = utcnow_iso() add_audit(state, "STATUS_CHANGED", agent, f"{old} → {new_status}") save_state(state_file, state) log.info(f" 📋 Statut: {old} → {new_status}") # ─── TASK BUILDER ────────────────────────────────────────────────────────────── def build_task_for_agent(agent_name: str, state: dict, state_file: Path) -> str: project = state.get("project_name", "Projet Inconnu") state_path = str(state_file) tasks = state.get("tasks", []) pending = [t for t in tasks if t.get("status") == "PENDING" and t.get("assigned_to", "").lower() == agent_name.lower().replace("foxy-", "foxy-")] base = ( f"Tu es {agent_name}. " f"Projet actif : {project}. " f"Fichier d'état : {state_path}. " f"Lis ce fichier IMMÉDIATEMENT, exécute ta mission selon ton rôle, " f"puis mets à jour project_state.json avec tes résultats et le nouveau statut. " ) instructions = { "Foxy-Conductor": ( base + "MISSION : Analyse la demande dans project_state.json. " "Clarife si besoin, sinon crée les tâches initiales dans tasks[], " "puis change status à 'AWAITING_ARCHITECT'. " "Ajoute ton entrée dans audit_log." ), "Foxy-Architect": ( base + "MISSION : Lis project_state.json. " "Produis l'architecture technique complète (ADR), " "découpe en tickets détaillés dans tasks[] avec assigned_to, " "acceptance_criteria, et depends_on. " "Détermine si le premier ticket est backend (→ status='AWAITING_DEV') " "ou frontend (→ status='AWAITING_UIUX'). " "Mets à jour project_state.json." ), "Foxy-Dev": ( base + f"MISSION : Prends la première tâche PENDING qui t'est assignée. " f"Tâches en attente : {json.dumps(pending, ensure_ascii=False)}. " "Écris le code complet, commit sur la branche task/TASK-XXX-description via Gitea. " "Change le statut de la tâche à 'IN_REVIEW', " "puis change status du projet à 'AWAITING_QA'. " "Mets à jour project_state.json." ), "Foxy-UIUX": ( base + f"MISSION : Prends la première tâche UI/PENDING qui t'est assignée. " f"Tâches en attente : {json.dumps(pending, ensure_ascii=False)}. " "Crée les composants React/TypeScript complets, commit sur branche task/TASK-XXX-ui-description. " "Change le statut de la tâche à 'IN_REVIEW', " "puis change status du projet à 'AWAITING_QA'. " "Mets à jour project_task.json." ), "Foxy-QA": ( base + "MISSION : Audite toutes les tâches avec statut 'IN_REVIEW'. " "Pour chaque tâche : vérifie sécurité (injections, variables exposées), qualité, tests. " "Si APPROUVÉ → statut tâche = 'READY_FOR_DEPLOY'. " "Si REJETÉ → statut tâche = 'PENDING' + ajoute qa_feedback dans la tâche + " "remet assigned_to à l'agent original. " "Si toutes tâches sont READY_FOR_DEPLOY → status projet = 'AWAITING_DEPLOY'. " "Sinon si des tâches rejetées → détermine si c'est DEV ou UIUX et change status en conséquence. " "Mets à jour project_state.json." ), "Foxy-Admin": ( base + "MISSION : Déploie toutes les tâches avec statut 'READY_FOR_DEPLOY'. " "Utilise SSH sur $DEPLOYMENT_SERVER, crée un backup avant déploiement. " "Change statut de chaque tâche à 'DONE'. " "Si toutes les tâches sont DONE → change status projet à 'COMPLETED' " "+ génère rapport final dans final_report. " "Mets à jour project_state.json. " "Envoie un résumé final via Telegram." ), } return instructions.get(agent_name, base + "Exécute ta mission et mets à jour project_state.json.") # ─── OPENCLAW SPAWN ──────────────────────────────────────────────────────────── def spawn_agent(agent_name: str, task_message: str, label_suffix: str = "") -> bool: """ Lance un agent via openclaw (syntaxe détectée au démarrage). Retourne True si le spawn a démarré correctement. """ global _OPENCLAW_SPAWN_CMD if _OPENCLAW_SPAWN_CMD is None: log.error("❌ Syntaxe openclaw non initialisée — probe non effectué ?") return False agent_label = AGENT_LABELS.get(agent_name, agent_name.lower().replace(" ", "-")) spawn_label = f"{agent_label}{'-' + label_suffix if label_suffix else ''}" cmd = build_spawn_cmd(_OPENCLAW_SPAWN_CMD, agent_label, task_message, spawn_label) log.info(f" 🚀 Spawn: {agent_name} (label: {spawn_label})") # Logger la commande sans le contenu du task (peut être très long) cmd_display = [c if len(c) < 60 else c[:57] + "..." for c in cmd] log.debug(f" CMD: {' '.join(cmd_display)}") try: proc = subprocess.Popen( cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env={**os.environ, "HOME": "/home/openclaw"}, cwd="/home/openclaw/.openclaw/workspace" ) # Attendre 3 secondes pour détecter un échec immédiat time.sleep(3) if proc.poll() is not None and proc.returncode != 0: _, stderr = proc.communicate(timeout=5) err_msg = stderr.decode()[:300] log.error(f" ❌ Spawn échoué (code {proc.returncode}): {err_msg}") # Si erreur "unknown option", afficher le help pour debug if "unknown option" in err_msg or "unrecognized" in err_msg.lower(): log.error(" 💡 Hint: La syntaxe openclaw a peut-être changé.") log.error(" 💡 Lance: openclaw sessions spawn --help") log.error(" 💡 Puis mets à jour probe_openclaw_syntax() en conséquence.") return False log.info(f" ✅ {agent_name} spawné (PID: {proc.pid})") return True except FileNotFoundError: log.error(" ❌ 'openclaw' introuvable dans PATH. Vérifie l'installation.") return False except Exception as e: log.error(f" ❌ Erreur spawn {agent_name}: {e}") return False def is_agent_running(agent_name: str) -> bool: """Vérifie si un processus openclaw pour cet agent est déjà actif.""" label = AGENT_LABELS.get(agent_name, "").lower() try: result = subprocess.run( ["pgrep", "-f", f"openclaw.*{label}"], capture_output=True, text=True ) return bool(result.stdout.strip()) except Exception: return False # ─── BOUCLE PRINCIPALE ───────────────────────────────────────────────────────── def find_project_states() -> list[Path]: states = [] for proj_dir in WORKSPACE.iterdir(): if proj_dir.is_dir(): sf = proj_dir / "project_state.json" if sf.exists(): states.append(sf) return states def process_project(state_file: Path): state = load_state(state_file) if not state: return status = state.get("status", "") project_name = state.get("project_name", state_file.parent.name) if status in RUNNING_STATUSES: if status not in ("COMPLETED", "FAILED"): log.debug(f" ⏳ {project_name}: {status} (agent actif)") return if status not in STATUS_TRANSITIONS: log.debug(f" ℹ️ {project_name}: statut '{status}' non géré par autopilot") return agent_name, running_status = STATUS_TRANSITIONS[status] log.info(f"📋 Projet: {project_name} | Statut: {status} → Agent: {agent_name}") if is_agent_running(agent_name): log.info(f" ⏳ {agent_name} déjà actif, on attend...") return task_msg = build_task_for_agent(agent_name, state, state_file) success = spawn_agent(agent_name, task_msg, label_suffix=state_file.parent.name[:20]) if success: mark_status(state_file, state, running_status, "foxy-autopilot") notify( f"🦊 Foxy Dev Team\n" f"📋 {project_name}\n" f"🤖 {agent_name} lancé\n" f"📊 Statut: {status} → {running_status}" ) else: log.error(f" ❌ Échec spawn {agent_name} pour {project_name}") notify( f"🦊 ⚠️ Foxy Dev Team — ERREUR\n" f"📋 {project_name}\n" f"❌ Impossible de lancer {agent_name}\n" f"Vérifie les logs : {LOG_FILE}" ) def check_stuck_agents(state_file: Path): state = load_state(state_file) if not state: return status = state.get("status", "") if status not in RUNNING_STATUSES or status in ("COMPLETED", "FAILED"): return updated_at_str = state.get("updated_at", state.get("created_at", "")) if not updated_at_str: return try: updated_at = datetime.fromisoformat(updated_at_str.replace("Z", "+00:00")) elapsed = (utcnow() - updated_at).total_seconds() except Exception: return if elapsed > SPAWN_TIMEOUT: project_name = state.get("project_name", state_file.parent.name) log.warning(f" ⚠️ {project_name} bloqué depuis {elapsed/3600:.1f}h — reset") awaiting = {v[1]: k for k, v in STATUS_TRANSITIONS.items()} reset_to = awaiting.get(status, "AWAITING_CONDUCTOR") mark_status(state_file, state, reset_to, "foxy-autopilot-watchdog") notify( f"🦊 ⚠️ Watchdog\n" f"📋 {project_name}\n" f"⏱️ Agent bloqué depuis {elapsed/3600:.1f}h\n" f"🔄 Reset → {reset_to}" ) def run_daemon(): global _OPENCLAW_SPAWN_CMD log.info("=" * 60) log.info("🦊 FOXY AUTO-PILOT DAEMON v2.1 — DÉMARRÉ") log.info(f" Workspace : {WORKSPACE}") log.info(f" Polling : {POLL_INTERVAL}s") log.info(f" Log : {LOG_FILE}") log.info("=" * 60) # ── Probe la syntaxe openclaw UNE SEULE FOIS au démarrage ── log.info("🔍 Détection syntaxe openclaw...") _OPENCLAW_SPAWN_CMD = probe_openclaw_syntax() if _OPENCLAW_SPAWN_CMD is None: log.error("❌ Impossible de détecter la syntaxe openclaw — daemon arrêté.") sys.exit(1) notify( "🦊 Foxy Auto-Pilot v2.1 démarré\n" f"⏱️ Polling toutes les {POLL_INTERVAL}s\n" "📂 Surveillance du workspace active" ) cycle = 0 while _running: cycle += 1 log.info(f"🔍 Cycle #{cycle} — {utcnow().strftime('%H:%M:%S')} UTC") try: state_files = find_project_states() if not state_files: log.info(" (aucun projet dans le workspace)") else: for sf in state_files: process_project(sf) check_stuck_agents(sf) except Exception as e: log.error(f"Erreur cycle #{cycle}: {e}", exc_info=True) log.info(f"⏳ Prochaine vérification dans {POLL_INTERVAL}s...\n") for _ in range(POLL_INTERVAL): if not _running: break time.sleep(1) log.info("🛑 Daemon arrêté proprement.") notify("🛑 Foxy Auto-Pilot arrêté") # ─── ENTRY POINT ─────────────────────────────────────────────────────────────── if __name__ == "__main__": if len(sys.argv) > 1 and sys.argv[1] == "--submit": if len(sys.argv) < 3: print("Usage: python3 foxy-autopilot.py --submit 'Description du projet'") sys.exit(1) description = " ".join(sys.argv[2:]) project_slug = "proj-" + utcnow().strftime("%Y%m%d-%H%M%S") proj_dir = WORKSPACE / project_slug proj_dir.mkdir(parents=True, exist_ok=True) state_file = proj_dir / "project_state.json" initial_state = { "project_name": project_slug, "description": description, "status": "AWAITING_CONDUCTOR", "created_at": utcnow_iso(), "updated_at": utcnow_iso(), "tasks": [], "audit_log": [{ "timestamp": utcnow_iso(), "action": "PROJECT_SUBMITTED", "agent": "user", "details": description[:200] }] } with open(state_file, "w") as f: json.dump(initial_state, f, indent=2, ensure_ascii=False) print(f"✅ Projet soumis : {project_slug}") print(f"📁 State file : {state_file}") print(f"🚀 Statut : AWAITING_CONDUCTOR") print(f"\nLe daemon va prendre en charge le projet au prochain cycle.") print(f"Surveille les logs : tail -f {LOG_FILE}") notify( f"🦊 Nouveau projet soumis !\n" f"📋 {project_slug}\n" f"📝 {description[:150]}\n" f"⏳ En attente de Foxy-Conductor..." ) elif len(sys.argv) > 1 and sys.argv[1] == "--probe": # Mode diagnostic : affiche la syntaxe détectée sans lancer le daemon print("🔍 Probe syntaxe openclaw...") cmd = probe_openclaw_syntax() if cmd: print(f"✅ Syntaxe détectée : {' '.join(cmd)}") else: print("❌ Aucune syntaxe détectée. Vérifie l'installation openclaw.") else: run_daemon()