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

629 lines
24 KiB
Python
Raw 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.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"🦊 <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):
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"🦊 ⚠️ <b>Watchdog</b>\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(
"🦊 <b>Foxy Auto-Pilot v2.1 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}{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("🛑 <b>Foxy Auto-Pilot arrêté</b>")
# ─── 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"🦊 <b>Nouveau projet soumis !</b>\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()