#!/usr/bin/env python3 """ 🦊 Foxy Dev Team — Auto-Pilot Daemon v2.2 ========================================== Architecture : Python daemon + openclaw agent (one-shot) - Surveille project_state.json via polling (30s) - Lance chaque agent via `openclaw agent` (syntaxe réelle détectée) - Notification Telegram à chaque étape - Watchdog : reset automatique si agent bloqué > SPAWN_TIMEOUT Auteur : Foxy Dev Team Usage : python3 foxy-autopilot.py [--submit "desc"] [--probe] [--reset-running] Service: systemctl --user start foxy-autopilot Changelog v2.2: - Fix: datetime.utcnow() → datetime.now(UTC) (DeprecationWarning) - Fix: Double-logging supprimé - Fix: Probe syntaxe openclaw — adapté à la vraie CLI (openclaw agent) - Fix: Probe sans appel réseau (--help local uniquement, timeout court) - Fix: Watchdog SPAWN_TIMEOUT réduit à 30min (était 2h, trop long pour debug) - New: --reset-running remet tous les projets RUNNING en AWAITING (debug) - New: Détection fin d'agent via suivi de PID (process_tracker) """ 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 = 1800 # 30min max par agent avant watchdog reset TELEGRAM_BOT = "8686313703:AAEGUunkJWbJx7njX_NUrW9HcyrZqXzA3KQ" TELEGRAM_CHAT = "8379645618" # Tracker { project_slug → (Popen, agent_name, awaiting_status) } # Permet de détecter si un agent s'est terminé sans mettre à jour le state _process_tracker: dict[str, tuple] = {} # 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], timeout: int = 8) -> str: """Lance une commande --help et retourne stdout+stderr combinés.""" try: r = subprocess.run( args, capture_output=True, text=True, timeout=timeout, env={**os.environ, "HOME": "/home/openclaw"} ) return (r.stdout + r.stderr).lower() except subprocess.TimeoutExpired: log.warning(f" ⚠️ Timeout({timeout}s) sur: {' '.join(args[:4])}") return "" except Exception: return "" def probe_openclaw_syntax() -> list[str] | None: """ Détecte la syntaxe réelle de la commande openclaw pour lancer un agent. Basé sur le help loggué de openclaw 2026.3.8 : - `openclaw agent` : "run one agent turn via the gateway" - `openclaw agents *` : "manage isolated agents" Syntaxes candidates testées dans l'ordre de priorité. Retourne le template de commande (avec {agent} et {task}) ou None. """ 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()}") # Candidats : (help_cmd, spawn_template, mots_clés_requis_dans_help) # Ordre : du plus spécifique au plus générique candidates = [ # ── Basé sur le help réel loggué ────────────────────────────────────── # `openclaw agent` = "run one agent turn via the gateway" ( ["openclaw", "agent", "--help"], ["openclaw", "agent", "--agent", "{agent}", "--task", "{task}"], ["agent", "task"] ), ( ["openclaw", "agent", "--help"], ["openclaw", "agent", "--agent", "{agent}", "--message", "{task}"], ["agent", "message"] ), ( ["openclaw", "agent", "--help"], ["openclaw", "agent", "{agent}", "--task", "{task}"], ["agent"] ), # `openclaw agents run` — sous-commande possible de `agents *` ( ["openclaw", "agents", "run", "--help"], ["openclaw", "agents", "run", "--agent", "{agent}", "--task", "{task}"], ["agent", "task"] ), ( ["openclaw", "agents", "run", "--help"], ["openclaw", "agents", "run", "{agent}", "--task", "{task}"], ["task"] ), # `openclaw agents spawn` — autre variante possible ( ["openclaw", "agents", "spawn", "--help"], ["openclaw", "agents", "spawn", "--agent", "{agent}", "--task", "{task}"], ["agent", "task"] ), # clawbot legacy (listé dans le help) ( ["openclaw", "clawbot", "--help"], ["openclaw", "clawbot", "run", "--agent", "{agent}", "--task", "{task}"], ["agent", "task"] ), ] for help_cmd, spawn_template, keywords in candidates: output = _run_help(help_cmd, timeout=8) if not output: continue if all(kw in output for kw in keywords): log.info(f"✅ Syntaxe openclaw détectée : {' '.join(spawn_template[:5])}") return spawn_template # Aucune syntaxe connue — logguer le help de chaque sous-commande pour debug log.warning("⚠️ Aucune syntaxe connue détectée.") log.warning(" Lance manuellement pour identifier la bonne syntaxe :") log.warning(" openclaw agent --help") log.warning(" openclaw agents --help") log.warning(" openclaw agents run --help (si disponible)") for dbg_cmd in [["openclaw", "agent", "--help"], ["openclaw", "agents", "--help"]]: out = _run_help(dbg_cmd, timeout=8) if out: log.warning(f" --- {' '.join(dbg_cmd)} ---") for line in out.splitlines()[:20]: log.warning(f" {line}") return None 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, project_slug: str) -> bool: """ Lance un agent via openclaw (syntaxe détectée au démarrage). Enregistre le process dans _process_tracker pour suivi. Retourne True si le spawn a démarré sans erreur immédiate. """ global _OPENCLAW_SPAWN_CMD, _process_tracker 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(" ", "-")) cmd = build_spawn_cmd(_OPENCLAW_SPAWN_CMD, agent_label, task_message, "") log.info(f" 🚀 Spawn: {agent_name} (agent: {agent_label})") cmd_display = " ".join(c if len(c) < 50 else c[:47] + "..." for c in cmd) log.info(f" CMD: {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 5 secondes pour détecter un échec immédiat time.sleep(5) if proc.poll() is not None and proc.returncode != 0: _, stderr = proc.communicate(timeout=5) err_msg = stderr.decode()[:400] log.error(f" ❌ Spawn échoué immédiatement (code {proc.returncode}):") for line in err_msg.splitlines(): log.error(f" {line}") log.error(" 💡 Lance: openclaw agent --help pour voir la syntaxe réelle") return False log.info(f" ✅ {agent_name} spawné (PID: {proc.pid})") # Enregistrer dans le tracker pour détecter la fin de l'agent awaiting = {v[1]: k for k, v in STATUS_TRANSITIONS.items()} _process_tracker[project_slug] = (proc, agent_name) return True except FileNotFoundError: log.error(" ❌ 'openclaw' introuvable dans PATH.") return False except Exception as e: log.error(f" ❌ Erreur spawn {agent_name}: {e}") return False def check_finished_agents(state_file: Path): """ Vérifie si un agent tracké s'est terminé sans mettre à jour le state. Si le process est mort mais le statut est encore RUNNING → reset en AWAITING. """ project_slug = state_file.parent.name if project_slug not in _process_tracker: return proc, agent_name = _process_tracker[project_slug] # Process encore en vie → rien à faire if proc.poll() is None: return # Process terminé — vérifier si le state a été mis à jour state = load_state(state_file) if not state: del _process_tracker[project_slug] return status = state.get("status", "") project_name = state.get("project_name", project_slug) exit_code = proc.returncode # Si le statut est encore RUNNING, l'agent s'est terminé sans mettre à jour le state if status in RUNNING_STATUSES and status not in ("COMPLETED", "FAILED"): awaiting = {v[1]: k for k, v in STATUS_TRANSITIONS.items()} reset_to = awaiting.get(status, "AWAITING_CONDUCTOR") if exit_code == 0: log.warning( f" ⚠️ {agent_name} terminé (code 0) mais state encore '{status}' " f"→ reset vers {reset_to}" ) else: stdout, stderr = proc.communicate() if proc.stdout else (b"", b"") err_snippet = (stderr or b"").decode()[:200] log.error( f" ❌ {agent_name} terminé en erreur (code {exit_code}): {err_snippet}" ) log.error(f" 🔄 Reset {project_name}: {status} → {reset_to}") mark_status(state_file, state, reset_to, f"foxy-autopilot-process-watcher") notify( f"🦊 ⚠️ Process Watcher\n" f"📋 {project_name}\n" f"🤖 {agent_name} terminé (code {exit_code}) sans mettre à jour le state\n" f"🔄 Reset → {reset_to}" ) else: log.info(f" ✅ {agent_name} terminé proprement (code {exit_code}), statut: {status}") del _process_tracker[project_slug] 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_slug = state_file.parent.name project_name = state.get("project_name", project_slug) if status in RUNNING_STATUSES: if status not in ("COMPLETED", "FAILED"): # Vérifier si le process tracké est mort sans mettre à jour le state check_finished_agents(state_file) 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, project_slug=project_slug) 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.2 — 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.2 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.") elif len(sys.argv) > 1 and sys.argv[1] == "--reset-running": # Mode debug : remet tous les projets RUNNING en AWAITING # Utile quand un agent s'est terminé sans mettre à jour le state print("🔄 Reset de tous les projets RUNNING → AWAITING...") awaiting_map = {v[1]: k for k, v in STATUS_TRANSITIONS.items()} count = 0 for sf in find_project_states(): state = load_state(sf) if not state: continue status = state.get("status", "") if status in RUNNING_STATUSES and status not in ("COMPLETED", "FAILED"): reset_to = awaiting_map.get(status, "AWAITING_CONDUCTOR") project_name = state.get("project_name", sf.parent.name) print(f" {project_name}: {status} → {reset_to}") mark_status(sf, state, reset_to, "foxy-autopilot-manual-reset") count += 1 print(f"✅ {count} projet(s) remis en attente.") else: run_daemon()