494 lines
20 KiB
Python
494 lines
20 KiB
Python
#!/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()
|