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

713 lines
28 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.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"🦊 ⚠️ <b>Process Watcher</b>\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"🦊 <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.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(
"🦊 <b>Foxy Auto-Pilot v2.2 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.")
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()