foxy-dev-team/scripts/foxy-autopilot.py.v2.0

494 lines
20 KiB
Python
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""
🦊 Foxy Dev Team — Auto-Pilot Daemon v2.0
==========================================
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
"""
import json
import os
import subprocess
import sys
import time
import signal
import logging
import urllib.request
import urllib.parse
from datetime import datetime
from pathlib import Path
# ─── 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
# Format: status_actuel → (agent_responsable, prochain_status_si_lancé)
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"),
}
# Statuts où l'agent est déjà en train de tourner → ne pas relancer
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)
logging.basicConfig(
level=logging.INFO,
format="[%(asctime)s] %(levelname)s %(message)s",
datefmt="%Y-%m-%dT%H:%M:%SZ",
handlers=[
logging.FileHandler(LOG_FILE),
logging.StreamHandler(sys.stdout),
]
)
log = logging.getLogger("foxy-autopilot")
# ─── 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}")
# ─── STATE HELPERS ─────────────────────────────────────────────────────────────
def load_state(state_file: Path) -> dict | None:
"""Charge le project_state.json de manière sécurisée."""
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):
"""Sauvegarde avec backup atomique."""
backup = state_file.with_suffix(".json.bak")
try:
# Backup de l'existant
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}")
# Restaurer le backup si échec
if backup.exists():
backup.rename(state_file)
def add_audit(state: dict, action: str, agent: str, details: str = ""):
"""Ajoute une entrée dans audit_log."""
state.setdefault("audit_log", []).append({
"timestamp": datetime.utcnow().isoformat() + "Z",
"action": action,
"agent": agent,
"details": details,
"source": "foxy-autopilot"
})
def mark_status(state_file: Path, state: dict, new_status: str, agent: str):
"""Met à jour le statut du projet et sauvegarde."""
old = state.get("status", "?")
state["status"] = new_status
state["updated_at"] = datetime.utcnow().isoformat() + "Z"
add_audit(state, "STATUS_CHANGED", agent, f"{old}{new_status}")
save_state(state_file, state)
log.info(f" 📋 Statut: {old}{new_status}")
# ─── OPENCLAW SPAWN ────────────────────────────────────────────────────────────
def build_task_for_agent(agent_name: str, state: dict, state_file: Path) -> str:
"""
Construit le message de tâche envoyé à l'agent via --task.
Chaque agent reçoit des instructions précises + le chemin du state file.
"""
project = state.get("project_name", "Projet Inconnu")
state_path = str(state_file)
tasks = state.get("tasks", [])
# Tâches en attente pour cet agent
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_state.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.")
def spawn_agent(agent_name: str, task_message: str, label_suffix: str = "") -> bool:
"""
Lance un agent via openclaw sessions spawn --mode run.
Retourne True si le spawn a démarré correctement.
"""
agent_label = AGENT_LABELS.get(agent_name, agent_name.lower().replace(" ", "-"))
spawn_label = f"{agent_label}{'-' + label_suffix if label_suffix else ''}"
cmd = [
"openclaw", "sessions", "spawn",
"--label", spawn_label,
"--agent", agent_label,
"--task", task_message,
"--mode", "run", # one-shot, non-interactif
"--runtime", "subagent", # sandbox isolé
]
log.info(f" 🚀 Spawn: {agent_name} (label: {spawn_label})")
log.debug(f" CMD: {' '.join(cmd[:6])}...") # Ne pas logger le task (peut être long)
try:
# Lance en arrière-plan (non-bloquant pour le daemon)
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 voir si ça démarre sans erreur immédiate
time.sleep(3)
if proc.poll() is not None and proc.returncode != 0:
_, stderr = proc.communicate(timeout=5)
log.error(f" ❌ Spawn échoué (code {proc.returncode}): {stderr.decode()[:200]}")
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]:
"""Trouve tous les project_state.json dans le workspace."""
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):
"""Traite un projet : détermine l'action à faire selon son statut."""
state = load_state(state_file)
if not state:
return
status = state.get("status", "")
project_name = state.get("project_name", state_file.parent.name)
# Ignorer les projets terminés ou en cours d'exécution
if status in RUNNING_STATUSES:
if status not in ("COMPLETED", "FAILED"):
log.debug(f"{project_name}: {status} (agent actif)")
return
# Chercher la transition applicable
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}")
# Vérifier que l'agent n'est pas déjà en train de tourner
if is_agent_running(agent_name):
log.info(f"{agent_name} déjà actif, on attend...")
return
# Construire la tâche et spawner
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:
# Marquer comme "en cours" pour éviter double-spawn
mark_status(state_file, state, running_status, "foxy-autopilot")
notify(
f"🦊 <b>Foxy Dev Team</b>\n"
f"📋 <b>{project_name}</b>\n"
f"🤖 {agent_name} lancé\n"
f"📊 Statut: {status}{running_status}"
)
else:
log.error(f" ❌ Échec spawn {agent_name} pour {project_name}")
notify(
f"🦊 ⚠️ <b>Foxy Dev Team — ERREUR</b>\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):
"""
Détecte les projets bloqués : si un agent tourne depuis trop longtemps
sans changer le statut, remet en AWAITING pour relance.
(Protection contre les agents figés)
"""
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 = (datetime.now().astimezone() - 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")
# Trouver le statut AWAITING correspondant
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"🦊 ⚠️ <b>Watchdog</b>\n"
f"📋 {project_name}\n"
f"⏱️ Agent bloqué depuis {elapsed/3600:.1f}h\n"
f"🔄 Reset → {reset_to}"
)
def run_daemon():
"""Boucle principale du daemon."""
log.info("=" * 60)
log.info("🦊 FOXY AUTO-PILOT DAEMON v2.0 — DÉMARRÉ")
log.info(f" Workspace : {WORKSPACE}")
log.info(f" Polling : {POLL_INTERVAL}s")
log.info(f" Log : {LOG_FILE}")
log.info("=" * 60)
notify(
"🦊 <b>Foxy Auto-Pilot démarré</b>\n"
f"⏱️ Polling toutes les {POLL_INTERVAL}s\n"
"📂 Surveillance du workspace active"
)
cycle = 0
while _running:
cycle += 1
log.info(f"🔍 Cycle #{cycle}{datetime.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")
# Sleep interruptible (réagit aux signaux)
for _ in range(POLL_INTERVAL):
if not _running:
break
time.sleep(1)
log.info("🛑 Daemon arrêté proprement.")
notify("🛑 <b>Foxy Auto-Pilot arrêté</b>")
# ─── ENTRY POINT ───────────────────────────────────────────────────────────────
if __name__ == "__main__":
if len(sys.argv) > 1 and sys.argv[1] == "--submit":
# Mode soumission de projet
# Usage: python3 foxy-autopilot.py --submit "Description du projet"
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-" + datetime.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": datetime.utcnow().isoformat() + "Z",
"updated_at": datetime.utcnow().isoformat() + "Z",
"tasks": [],
"audit_log": [{
"timestamp": datetime.utcnow().isoformat() + "Z",
"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"🦊 <b>Nouveau projet soumis !</b>\n"
f"📋 {project_slug}\n"
f"📝 {description[:150]}\n"
f"⏳ En attente de Foxy-Conductor..."
)
else:
# Mode daemon normal
run_daemon()