#!/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()