chore: install frontend dependencies.
This commit is contained in:
@@ -0,0 +1,772 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
🦊 Foxy Dev Team — Auto-Pilot Daemon v2.3
|
||||
==========================================
|
||||
Auteur : Foxy Dev Team
|
||||
Usage : python3 foxy-autopilot.py [--submit "desc"] [--probe] [--reset-running]
|
||||
Service: systemctl --user start foxy-autopilot
|
||||
|
||||
Changelog v2.3:
|
||||
- Fix: datetime.utcnow() → datetime.now(UTC)
|
||||
- Fix: PID lock — empêche deux instances simultanées
|
||||
- Fix: Probe syntaxe openclaw adapté à la vraie CLI (openclaw agent)
|
||||
- Fix: Variables session X11 injectées depuis /proc/<gateway-pid>/environ
|
||||
- Fix: Compteur d'échecs + suspension après MAX_CONSECUTIVE_FAILURES
|
||||
- Fix: load_state avec retry pour race condition JSON
|
||||
- Fix: spawn_agent défini avant process_project (NameError corrigé)
|
||||
- New: check_finished_agents — détecte fin d'agent sans mise à jour du state
|
||||
- New: --reset-running pour déblocage manuel
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import fcntl
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import signal
|
||||
import logging
|
||||
import urllib.request
|
||||
import urllib.parse
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
# ─── UTC ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
UTC = timezone.utc
|
||||
|
||||
def utcnow() -> datetime:
|
||||
return datetime.now(UTC)
|
||||
|
||||
def utcnow_iso() -> str:
|
||||
return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
# ─── CONFIG ────────────────────────────────────────────────────────────────────
|
||||
|
||||
WORKSPACE = Path("/home/openclaw/.openclaw/workspace")
|
||||
LOG_FILE = Path("/home/openclaw/.openclaw/logs/foxy-autopilot.log")
|
||||
PID_FILE = Path("/home/openclaw/.openclaw/logs/foxy-autopilot.pid")
|
||||
POLL_INTERVAL = 30
|
||||
SPAWN_TIMEOUT = 1800
|
||||
TELEGRAM_BOT = "8686313703:AAEGUunkJWbJx7njX_NUrW9HcyrZqXzA3KQ"
|
||||
TELEGRAM_CHAT = "8379645618"
|
||||
MAX_CONSECUTIVE_FAILURES = 3
|
||||
SUSPEND_DURATION_CYCLES = 20
|
||||
|
||||
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",
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
|
||||
# ─── ÉTAT GLOBAL ───────────────────────────────────────────────────────────────
|
||||
|
||||
_process_tracker: dict[str, tuple] = {} # { slug → (Popen, agent_name) }
|
||||
_failure_counter: dict[str, int] = {} # { slug → nb_echecs }
|
||||
_suspended_until: dict[str, int] = {} # { slug → cycle_reprise }
|
||||
_OPENCLAW_SPAWN_CMD: list[str]|None = None
|
||||
_pid_lock_fh = None
|
||||
|
||||
# ─── LOGGING ───────────────────────────────────────────────────────────────────
|
||||
|
||||
LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
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
|
||||
|
||||
# ─── 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)
|
||||
|
||||
# ─── PID LOCK ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def acquire_pid_lock() -> bool:
|
||||
global _pid_lock_fh
|
||||
try:
|
||||
PID_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
_pid_lock_fh = open(PID_FILE, "w")
|
||||
fcntl.flock(_pid_lock_fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
_pid_lock_fh.write(str(os.getpid()))
|
||||
_pid_lock_fh.flush()
|
||||
return True
|
||||
except BlockingIOError:
|
||||
try:
|
||||
existing = PID_FILE.read_text().strip()
|
||||
print(f"❌ Une instance est déjà en cours (PID {existing}). Abandon.")
|
||||
except Exception:
|
||||
print("❌ Une instance est déjà en cours. Abandon.")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"❌ Impossible d'acquérir le PID lock: {e}")
|
||||
return False
|
||||
|
||||
# ─── TELEGRAM ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def notify(msg: str):
|
||||
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 sanitize_state(state: dict, source: str) -> dict:
|
||||
"""
|
||||
Valide et nettoie le state chargé.
|
||||
Filtre les éléments corrompus dans tasks[] (strings au lieu de dicts).
|
||||
"""
|
||||
tasks = state.get("tasks", [])
|
||||
if not isinstance(tasks, list):
|
||||
log.warning(f" ⚠️ [{source}] tasks[] n'est pas une liste — réinitialisé")
|
||||
state["tasks"] = []
|
||||
return state
|
||||
|
||||
clean = []
|
||||
for i, t in enumerate(tasks):
|
||||
if isinstance(t, dict):
|
||||
clean.append(t)
|
||||
else:
|
||||
log.warning(f" ⚠️ [{source}] tasks[{i}] est un {type(t).__name__} (ignoré): {repr(t)[:80]}")
|
||||
if len(clean) != len(tasks):
|
||||
log.warning(f" ⚠️ [{source}] {len(tasks) - len(clean)} tâche(s) invalide(s) filtrée(s)")
|
||||
state["tasks"] = clean
|
||||
return state
|
||||
|
||||
def load_state(state_file: Path, retries: int = 3, delay: float = 0.5) -> dict|None:
|
||||
"""Charge project_state.json avec retry pour absorber les race conditions."""
|
||||
for attempt in range(1, retries + 1):
|
||||
try:
|
||||
with open(state_file) as f:
|
||||
data = json.load(f)
|
||||
return sanitize_state(data, state_file.parent.name)
|
||||
except json.JSONDecodeError as e:
|
||||
if attempt < retries:
|
||||
log.debug(f"JSON invalide ({attempt}/{retries}), retry dans {delay}s: {e}")
|
||||
time.sleep(delay)
|
||||
else:
|
||||
backup = state_file.with_suffix(".json.bak")
|
||||
if backup.exists():
|
||||
log.warning(f"JSON invalide dans {state_file.name} — utilisation du backup")
|
||||
try:
|
||||
with open(backup) as f:
|
||||
data = json.load(f)
|
||||
return sanitize_state(data, state_file.parent.name + ".bak")
|
||||
except Exception:
|
||||
pass
|
||||
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
|
||||
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}")
|
||||
|
||||
# ─── PROBE SYNTAXE OPENCLAW ────────────────────────────────────────────────────
|
||||
|
||||
def _run_help(args: list[str], timeout: int = 8) -> str:
|
||||
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:
|
||||
which = subprocess.run(["which", "openclaw"], capture_output=True, text=True)
|
||||
if which.returncode != 0:
|
||||
log.error("❌ 'openclaw' introuvable dans PATH.")
|
||||
return None
|
||||
log.info(f"✅ openclaw trouvé : {which.stdout.strip()}")
|
||||
|
||||
candidates = [
|
||||
(
|
||||
["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", "--help"],
|
||||
["openclaw", "agents", "run", "--agent", "{agent}", "--task", "{task}"],
|
||||
["agent", "task"]
|
||||
),
|
||||
(
|
||||
["openclaw", "agents", "spawn", "--help"],
|
||||
["openclaw", "agents", "spawn", "--agent", "{agent}", "--task", "{task}"],
|
||||
["agent", "task"]
|
||||
),
|
||||
(
|
||||
["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
|
||||
|
||||
log.warning("⚠️ Aucune syntaxe connue détectée.")
|
||||
log.warning(" Lance: openclaw agent --help pour voir la syntaxe réelle")
|
||||
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) -> list[str]:
|
||||
return [
|
||||
t.replace("{agent}", agent_label).replace("{task}", task_msg)
|
||||
for t in template
|
||||
]
|
||||
|
||||
# ─── SESSION ENV ───────────────────────────────────────────────────────────────
|
||||
|
||||
def _get_session_env() -> dict:
|
||||
"""
|
||||
Injecte les variables de session X11/KDE dans l'environnement des agents.
|
||||
Le daemon systemd --user n'a pas DBUS_SESSION_BUS_ADDRESS ni XDG_RUNTIME_DIR.
|
||||
On les copie depuis le process openclaw-gateway qui tourne dans la vraie session.
|
||||
"""
|
||||
env = {**os.environ, "HOME": "/home/openclaw"}
|
||||
|
||||
try:
|
||||
gw_result = subprocess.run(
|
||||
["pgrep", "-u", "openclaw", "-x", "openclaw-gateway"],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
gw_pid = gw_result.stdout.strip().splitlines()[0] if gw_result.stdout.strip() else None
|
||||
except Exception:
|
||||
gw_pid = None
|
||||
|
||||
if gw_pid:
|
||||
try:
|
||||
with open(f"/proc/{gw_pid}/environ", "rb") as f:
|
||||
raw = f.read()
|
||||
SESSION_VARS = {
|
||||
"DBUS_SESSION_BUS_ADDRESS", "XDG_RUNTIME_DIR", "DISPLAY",
|
||||
"XAUTHORITY", "XDG_SESSION_TYPE", "XDG_CURRENT_DESKTOP",
|
||||
}
|
||||
injected = []
|
||||
for item in raw.split(b"\x00"):
|
||||
if b"=" not in item:
|
||||
continue
|
||||
key, _, val = item.partition(b"=")
|
||||
key_str = key.decode(errors="replace")
|
||||
if key_str in SESSION_VARS:
|
||||
env[key_str] = val.decode(errors="replace")
|
||||
injected.append(key_str)
|
||||
if injected:
|
||||
log.debug(f" ENV injecté depuis gateway PID {gw_pid}: {', '.join(injected)}")
|
||||
except PermissionError:
|
||||
log.warning(f" ⚠️ Accès refusé à /proc/{gw_pid}/environ")
|
||||
except Exception as e:
|
||||
log.warning(f" ⚠️ Impossible de lire l'env du gateway: {e}")
|
||||
else:
|
||||
log.warning(" ⚠️ openclaw-gateway introuvable via pgrep")
|
||||
|
||||
uid_r = subprocess.run(["id", "-u"], capture_output=True, text=True)
|
||||
uid = uid_r.stdout.strip()
|
||||
if uid:
|
||||
env.setdefault("XDG_RUNTIME_DIR", f"/run/user/{uid}")
|
||||
env.setdefault("DBUS_SESSION_BUS_ADDRESS", f"unix:path=/run/user/{uid}/bus")
|
||||
|
||||
return env
|
||||
|
||||
# ─── 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", [])
|
||||
test_mode = state.get("test_mode", False)
|
||||
|
||||
pending = [t for t in tasks
|
||||
if isinstance(t, dict)
|
||||
and t.get("status") == "PENDING"
|
||||
and t.get("assigned_to", "").lower() == agent_name.lower()]
|
||||
|
||||
base = (
|
||||
f"Tu es {agent_name}. "
|
||||
f"Projet actif : {project}. "
|
||||
f"Fichier d'état : {state_path}. "
|
||||
f"{'MODE TEST : simule ton travail sans produire de code réel. ' if test_mode else ''}"
|
||||
f"Lis ce fichier IMMÉDIATEMENT, exécute ta mission, "
|
||||
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. "
|
||||
"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 (ADR), "
|
||||
"découpe en tickets dans tasks[] avec assigned_to, acceptance_criteria, depends_on. "
|
||||
"Change status à 'AWAITING_DEV' ou 'AWAITING_UIUX'. "
|
||||
"Mets à jour project_state.json."
|
||||
),
|
||||
"Foxy-Dev": (
|
||||
base +
|
||||
f"MISSION : Prends la première tâche PENDING assignée à toi. "
|
||||
f"Tâches en attente : {json.dumps(pending, ensure_ascii=False)}. "
|
||||
"Écris le code, commit sur branche task/TASK-XXX via Gitea. "
|
||||
"Change statut tâche → 'IN_REVIEW', projet → 'AWAITING_QA'. "
|
||||
"Mets à jour project_state.json."
|
||||
),
|
||||
"Foxy-UIUX": (
|
||||
base +
|
||||
f"MISSION : Prends la première tâche UI/PENDING assignée à toi. "
|
||||
f"Tâches en attente : {json.dumps(pending, ensure_ascii=False)}. "
|
||||
"Crée les composants React/TypeScript, commit sur branche task/TASK-XXX-ui. "
|
||||
"Change statut tâche → 'IN_REVIEW', projet → 'AWAITING_QA'. "
|
||||
"Mets à jour project_state.json."
|
||||
),
|
||||
"Foxy-QA": (
|
||||
base +
|
||||
"MISSION : Audite toutes les tâches 'IN_REVIEW'. "
|
||||
"Si APPROUVÉ → statut tâche = 'READY_FOR_DEPLOY'. "
|
||||
"Si REJETÉ → statut tâche = 'PENDING' + qa_feedback + reassign agent original. "
|
||||
"Si toutes READY_FOR_DEPLOY → status projet = 'AWAITING_DEPLOY'. "
|
||||
"Sinon → status = 'AWAITING_DEV' ou 'AWAITING_UIUX'. "
|
||||
"Mets à jour project_state.json."
|
||||
),
|
||||
"Foxy-Admin": (
|
||||
base +
|
||||
"MISSION : Déploie toutes les tâches 'READY_FOR_DEPLOY'. "
|
||||
"Backup avant déploiement. Change chaque tâche → 'DONE'. "
|
||||
"Si tout DONE → status projet = 'COMPLETED' + génère final_report. "
|
||||
"Mets à jour project_state.json."
|
||||
),
|
||||
}
|
||||
|
||||
return instructions.get(agent_name, base + "Exécute ta mission et mets à jour project_state.json.")
|
||||
|
||||
# ─── SPAWN AGENT ───────────────────────────────────────────────────────────────
|
||||
|
||||
def spawn_agent(agent_name: str, task_message: str, project_slug: str) -> bool:
|
||||
"""Lance un agent openclaw et l'enregistre dans _process_tracker."""
|
||||
global _OPENCLAW_SPAWN_CMD, _process_tracker
|
||||
|
||||
if _OPENCLAW_SPAWN_CMD is None:
|
||||
log.error("❌ Syntaxe openclaw non initialisée.")
|
||||
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:
|
||||
session_env = _get_session_env()
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
env=session_env,
|
||||
cwd="/home/openclaw/.openclaw/workspace"
|
||||
)
|
||||
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})")
|
||||
_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 is_agent_running(agent_name: str, project_slug: str) -> bool:
|
||||
"""
|
||||
Vérifie si un agent est actif pour ce projet via le _process_tracker.
|
||||
N'utilise plus pgrep pour éviter les faux positifs.
|
||||
"""
|
||||
if project_slug in _process_tracker:
|
||||
proc, tracked_agent = _process_tracker[project_slug]
|
||||
if tracked_agent == agent_name and proc.poll() is None:
|
||||
return True
|
||||
return False
|
||||
|
||||
# ─── PROCESS WATCHER ───────────────────────────────────────────────────────────
|
||||
|
||||
def check_finished_agents(state_file: Path):
|
||||
"""Détecte un agent terminé sans avoir mis à jour le state → reset en AWAITING."""
|
||||
project_slug = state_file.parent.name
|
||||
if project_slug not in _process_tracker:
|
||||
return
|
||||
|
||||
proc, agent_name = _process_tracker[project_slug]
|
||||
if proc.poll() is None:
|
||||
return # encore en vie
|
||||
|
||||
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
|
||||
|
||||
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")
|
||||
|
||||
try:
|
||||
_, stderr = proc.communicate(timeout=2)
|
||||
err_snippet = (stderr or b"").decode()[:300].strip()
|
||||
except Exception:
|
||||
err_snippet = ""
|
||||
|
||||
_failure_counter[project_slug] = _failure_counter.get(project_slug, 0) + 1
|
||||
nb = _failure_counter[project_slug]
|
||||
|
||||
if exit_code != 0:
|
||||
log.error(f" ❌ {agent_name} terminé en erreur (code {exit_code}):")
|
||||
for line in err_snippet.splitlines():
|
||||
log.error(f" {line}")
|
||||
|
||||
log.error(f" 🔄 Reset {project_name}: {status} → {reset_to} (échec #{nb})")
|
||||
mark_status(state_file, state, reset_to, "foxy-autopilot-process-watcher")
|
||||
|
||||
if nb >= MAX_CONSECUTIVE_FAILURES:
|
||||
_suspended_until[project_slug] = 0
|
||||
log.error(
|
||||
f" 🚫 {project_name}: {nb} échecs consécutifs — SUSPENDU "
|
||||
f"({SUSPEND_DURATION_CYCLES} cycles).\n"
|
||||
f" 💡 Vérifie: systemctl --user status openclaw-gateway"
|
||||
)
|
||||
notify(
|
||||
f"🦊 🚫 <b>Projet SUSPENDU</b>\n"
|
||||
f"📋 {project_name}\n"
|
||||
f"❌ {nb} échecs consécutifs de {agent_name}\n"
|
||||
f"⏸️ Pause de {SUSPEND_DURATION_CYCLES * POLL_INTERVAL}s\n"
|
||||
f"<code>{err_snippet[:150]}</code>"
|
||||
)
|
||||
else:
|
||||
notify(
|
||||
f"🦊 ⚠️ <b>Agent échoué ({nb}/{MAX_CONSECUTIVE_FAILURES})</b>\n"
|
||||
f"📋 {project_name} — {agent_name} (code {exit_code})\n"
|
||||
f"🔄 Reset → {reset_to}"
|
||||
)
|
||||
else:
|
||||
_failure_counter[project_slug] = 0
|
||||
log.info(f" ✅ {agent_name} terminé proprement (code {exit_code}), statut: {status}")
|
||||
|
||||
del _process_tracker[project_slug]
|
||||
|
||||
# ─── WATCHDOG ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def check_stuck_agents(state_file: Path):
|
||||
"""Reset si un agent tourne depuis plus de SPAWN_TIMEOUT sans changer le state."""
|
||||
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"⏱️ Bloqué depuis {elapsed/3600:.1f}h → Reset {reset_to}"
|
||||
)
|
||||
|
||||
# ─── 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, current_cycle: int = 0):
|
||||
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)
|
||||
|
||||
# Vérifier suspension
|
||||
if project_slug in _suspended_until:
|
||||
resume_at = _suspended_until[project_slug]
|
||||
if resume_at == 0:
|
||||
_suspended_until[project_slug] = current_cycle + SUSPEND_DURATION_CYCLES
|
||||
log.warning(f" 🚫 {project_name}: suspendu jusqu'au cycle #{current_cycle + SUSPEND_DURATION_CYCLES}")
|
||||
return
|
||||
elif current_cycle < resume_at:
|
||||
log.warning(f" 🚫 {project_name}: suspendu encore {resume_at - current_cycle} cycle(s)")
|
||||
return
|
||||
else:
|
||||
log.info(f" ▶️ {project_name}: suspension levée, reprise")
|
||||
del _suspended_until[project_slug]
|
||||
_failure_counter[project_slug] = 0
|
||||
|
||||
if status in RUNNING_STATUSES:
|
||||
if status not in ("COMPLETED", "FAILED"):
|
||||
check_finished_agents(state_file)
|
||||
return
|
||||
|
||||
if status not in STATUS_TRANSITIONS:
|
||||
log.debug(f" ℹ️ {project_name}: statut '{status}' non géré")
|
||||
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, project_slug):
|
||||
log.info(f" ⏳ {agent_name} déjà actif pour {project_slug}, 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"📊 {status} → {running_status}"
|
||||
)
|
||||
else:
|
||||
log.error(f" ❌ Échec spawn {agent_name} pour {project_name}")
|
||||
notify(
|
||||
f"🦊 ⚠️ <b>Échec spawn</b>\n"
|
||||
f"📋 {project_name}\n"
|
||||
f"❌ Impossible de lancer {agent_name}\n"
|
||||
f"Vérifie les logs : {LOG_FILE}"
|
||||
)
|
||||
|
||||
# ─── DAEMON ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def run_daemon():
|
||||
global _OPENCLAW_SPAWN_CMD
|
||||
|
||||
if not acquire_pid_lock():
|
||||
sys.exit(1)
|
||||
|
||||
log.info("=" * 60)
|
||||
log.info("🦊 FOXY AUTO-PILOT DAEMON v2.3 — DÉMARRÉ")
|
||||
log.info(f" Workspace : {WORKSPACE}")
|
||||
log.info(f" Polling : {POLL_INTERVAL}s")
|
||||
log.info(f" Log : {LOG_FILE}")
|
||||
log.info("=" * 60)
|
||||
|
||||
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.3 démarré</b>\n"
|
||||
f"⏱️ Polling toutes les {POLL_INTERVAL}s"
|
||||
)
|
||||
|
||||
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, current_cycle=cycle)
|
||||
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'")
|
||||
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}")
|
||||
|
||||
elif len(sys.argv) > 1 and sys.argv[1] == "--probe":
|
||||
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.")
|
||||
|
||||
elif len(sys.argv) > 1 and sys.argv[1] == "--reset-running":
|
||||
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()
|
||||
@@ -0,0 +1,493 @@
|
||||
#!/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()
|
||||
@@ -0,0 +1,628 @@
|
||||
#!/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()
|
||||
@@ -0,0 +1,712 @@
|
||||
#!/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()
|
||||
@@ -0,0 +1,746 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
🦊 Foxy Dev Team — Auto-Pilot Daemon v2.3
|
||||
==========================================
|
||||
Auteur : Foxy Dev Team
|
||||
Usage : python3 foxy-autopilot.py [--submit "desc"] [--probe] [--reset-running]
|
||||
Service: systemctl --user start foxy-autopilot
|
||||
|
||||
Changelog v2.3:
|
||||
- Fix: datetime.utcnow() → datetime.now(UTC)
|
||||
- Fix: PID lock — empêche deux instances simultanées
|
||||
- Fix: Probe syntaxe openclaw adapté à la vraie CLI (openclaw agent)
|
||||
- Fix: Variables session X11 injectées depuis /proc/<gateway-pid>/environ
|
||||
- Fix: Compteur d'échecs + suspension après MAX_CONSECUTIVE_FAILURES
|
||||
- Fix: load_state avec retry pour race condition JSON
|
||||
- Fix: spawn_agent défini avant process_project (NameError corrigé)
|
||||
- New: check_finished_agents — détecte fin d'agent sans mise à jour du state
|
||||
- New: --reset-running pour déblocage manuel
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import fcntl
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import signal
|
||||
import logging
|
||||
import urllib.request
|
||||
import urllib.parse
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
# ─── UTC ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
UTC = timezone.utc
|
||||
|
||||
def utcnow() -> datetime:
|
||||
return datetime.now(UTC)
|
||||
|
||||
def utcnow_iso() -> str:
|
||||
return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
# ─── CONFIG ────────────────────────────────────────────────────────────────────
|
||||
|
||||
WORKSPACE = Path("/home/openclaw/.openclaw/workspace")
|
||||
LOG_FILE = Path("/home/openclaw/.openclaw/logs/foxy-autopilot.log")
|
||||
PID_FILE = Path("/home/openclaw/.openclaw/logs/foxy-autopilot.pid")
|
||||
POLL_INTERVAL = 30
|
||||
SPAWN_TIMEOUT = 1800
|
||||
TELEGRAM_BOT = "8686313703:AAEGUunkJWbJx7njX_NUrW9HcyrZqXzA3KQ"
|
||||
TELEGRAM_CHAT = "8379645618"
|
||||
MAX_CONSECUTIVE_FAILURES = 3
|
||||
SUSPEND_DURATION_CYCLES = 20
|
||||
|
||||
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",
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
|
||||
# ─── ÉTAT GLOBAL ───────────────────────────────────────────────────────────────
|
||||
|
||||
_process_tracker: dict[str, tuple] = {} # { slug → (Popen, agent_name) }
|
||||
_failure_counter: dict[str, int] = {} # { slug → nb_echecs }
|
||||
_suspended_until: dict[str, int] = {} # { slug → cycle_reprise }
|
||||
_OPENCLAW_SPAWN_CMD: list[str]|None = None
|
||||
_pid_lock_fh = None
|
||||
|
||||
# ─── LOGGING ───────────────────────────────────────────────────────────────────
|
||||
|
||||
LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
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
|
||||
|
||||
# ─── 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)
|
||||
|
||||
# ─── PID LOCK ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def acquire_pid_lock() -> bool:
|
||||
global _pid_lock_fh
|
||||
try:
|
||||
PID_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
_pid_lock_fh = open(PID_FILE, "w")
|
||||
fcntl.flock(_pid_lock_fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
_pid_lock_fh.write(str(os.getpid()))
|
||||
_pid_lock_fh.flush()
|
||||
return True
|
||||
except BlockingIOError:
|
||||
try:
|
||||
existing = PID_FILE.read_text().strip()
|
||||
print(f"❌ Une instance est déjà en cours (PID {existing}). Abandon.")
|
||||
except Exception:
|
||||
print("❌ Une instance est déjà en cours. Abandon.")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"❌ Impossible d'acquérir le PID lock: {e}")
|
||||
return False
|
||||
|
||||
# ─── TELEGRAM ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def notify(msg: str):
|
||||
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, retries: int = 3, delay: float = 0.5) -> dict|None:
|
||||
for attempt in range(1, retries + 1):
|
||||
try:
|
||||
with open(state_file) as f:
|
||||
return json.load(f)
|
||||
except json.JSONDecodeError as e:
|
||||
if attempt < retries:
|
||||
log.debug(f"JSON invalide ({attempt}/{retries}), retry dans {delay}s: {e}")
|
||||
time.sleep(delay)
|
||||
else:
|
||||
backup = state_file.with_suffix(".json.bak")
|
||||
if backup.exists():
|
||||
log.warning(f"JSON invalide dans {state_file.name} — utilisation du backup")
|
||||
try:
|
||||
with open(backup) as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
pass
|
||||
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
|
||||
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}")
|
||||
|
||||
# ─── PROBE SYNTAXE OPENCLAW ────────────────────────────────────────────────────
|
||||
|
||||
def _run_help(args: list[str], timeout: int = 8) -> str:
|
||||
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:
|
||||
which = subprocess.run(["which", "openclaw"], capture_output=True, text=True)
|
||||
if which.returncode != 0:
|
||||
log.error("❌ 'openclaw' introuvable dans PATH.")
|
||||
return None
|
||||
log.info(f"✅ openclaw trouvé : {which.stdout.strip()}")
|
||||
|
||||
candidates = [
|
||||
(
|
||||
["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", "--help"],
|
||||
["openclaw", "agents", "run", "--agent", "{agent}", "--task", "{task}"],
|
||||
["agent", "task"]
|
||||
),
|
||||
(
|
||||
["openclaw", "agents", "spawn", "--help"],
|
||||
["openclaw", "agents", "spawn", "--agent", "{agent}", "--task", "{task}"],
|
||||
["agent", "task"]
|
||||
),
|
||||
(
|
||||
["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
|
||||
|
||||
log.warning("⚠️ Aucune syntaxe connue détectée.")
|
||||
log.warning(" Lance: openclaw agent --help pour voir la syntaxe réelle")
|
||||
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) -> list[str]:
|
||||
return [
|
||||
t.replace("{agent}", agent_label).replace("{task}", task_msg)
|
||||
for t in template
|
||||
]
|
||||
|
||||
# ─── SESSION ENV ───────────────────────────────────────────────────────────────
|
||||
|
||||
def _get_session_env() -> dict:
|
||||
"""
|
||||
Injecte les variables de session X11/KDE dans l'environnement des agents.
|
||||
Le daemon systemd --user n'a pas DBUS_SESSION_BUS_ADDRESS ni XDG_RUNTIME_DIR.
|
||||
On les copie depuis le process openclaw-gateway qui tourne dans la vraie session.
|
||||
"""
|
||||
env = {**os.environ, "HOME": "/home/openclaw"}
|
||||
|
||||
try:
|
||||
gw_result = subprocess.run(
|
||||
["pgrep", "-u", "openclaw", "-x", "openclaw-gateway"],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
gw_pid = gw_result.stdout.strip().splitlines()[0] if gw_result.stdout.strip() else None
|
||||
except Exception:
|
||||
gw_pid = None
|
||||
|
||||
if gw_pid:
|
||||
try:
|
||||
with open(f"/proc/{gw_pid}/environ", "rb") as f:
|
||||
raw = f.read()
|
||||
SESSION_VARS = {
|
||||
"DBUS_SESSION_BUS_ADDRESS", "XDG_RUNTIME_DIR", "DISPLAY",
|
||||
"XAUTHORITY", "XDG_SESSION_TYPE", "XDG_CURRENT_DESKTOP",
|
||||
}
|
||||
injected = []
|
||||
for item in raw.split(b"\x00"):
|
||||
if b"=" not in item:
|
||||
continue
|
||||
key, _, val = item.partition(b"=")
|
||||
key_str = key.decode(errors="replace")
|
||||
if key_str in SESSION_VARS:
|
||||
env[key_str] = val.decode(errors="replace")
|
||||
injected.append(key_str)
|
||||
if injected:
|
||||
log.debug(f" ENV injecté depuis gateway PID {gw_pid}: {', '.join(injected)}")
|
||||
except PermissionError:
|
||||
log.warning(f" ⚠️ Accès refusé à /proc/{gw_pid}/environ")
|
||||
except Exception as e:
|
||||
log.warning(f" ⚠️ Impossible de lire l'env du gateway: {e}")
|
||||
else:
|
||||
log.warning(" ⚠️ openclaw-gateway introuvable via pgrep")
|
||||
|
||||
uid_r = subprocess.run(["id", "-u"], capture_output=True, text=True)
|
||||
uid = uid_r.stdout.strip()
|
||||
if uid:
|
||||
env.setdefault("XDG_RUNTIME_DIR", f"/run/user/{uid}")
|
||||
env.setdefault("DBUS_SESSION_BUS_ADDRESS", f"unix:path=/run/user/{uid}/bus")
|
||||
|
||||
return env
|
||||
|
||||
# ─── 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", [])
|
||||
test_mode = state.get("test_mode", False)
|
||||
|
||||
pending = [t for t in tasks if t.get("status") == "PENDING"
|
||||
and t.get("assigned_to", "").lower() == agent_name.lower()]
|
||||
|
||||
base = (
|
||||
f"Tu es {agent_name}. "
|
||||
f"Projet actif : {project}. "
|
||||
f"Fichier d'état : {state_path}. "
|
||||
f"{'MODE TEST : simule ton travail sans produire de code réel. ' if test_mode else ''}"
|
||||
f"Lis ce fichier IMMÉDIATEMENT, exécute ta mission, "
|
||||
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. "
|
||||
"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 (ADR), "
|
||||
"découpe en tickets dans tasks[] avec assigned_to, acceptance_criteria, depends_on. "
|
||||
"Change status à 'AWAITING_DEV' ou 'AWAITING_UIUX'. "
|
||||
"Mets à jour project_state.json."
|
||||
),
|
||||
"Foxy-Dev": (
|
||||
base +
|
||||
f"MISSION : Prends la première tâche PENDING assignée à toi. "
|
||||
f"Tâches en attente : {json.dumps(pending, ensure_ascii=False)}. "
|
||||
"Écris le code, commit sur branche task/TASK-XXX via Gitea. "
|
||||
"Change statut tâche → 'IN_REVIEW', projet → 'AWAITING_QA'. "
|
||||
"Mets à jour project_state.json."
|
||||
),
|
||||
"Foxy-UIUX": (
|
||||
base +
|
||||
f"MISSION : Prends la première tâche UI/PENDING assignée à toi. "
|
||||
f"Tâches en attente : {json.dumps(pending, ensure_ascii=False)}. "
|
||||
"Crée les composants React/TypeScript, commit sur branche task/TASK-XXX-ui. "
|
||||
"Change statut tâche → 'IN_REVIEW', projet → 'AWAITING_QA'. "
|
||||
"Mets à jour project_state.json."
|
||||
),
|
||||
"Foxy-QA": (
|
||||
base +
|
||||
"MISSION : Audite toutes les tâches 'IN_REVIEW'. "
|
||||
"Si APPROUVÉ → statut tâche = 'READY_FOR_DEPLOY'. "
|
||||
"Si REJETÉ → statut tâche = 'PENDING' + qa_feedback + reassign agent original. "
|
||||
"Si toutes READY_FOR_DEPLOY → status projet = 'AWAITING_DEPLOY'. "
|
||||
"Sinon → status = 'AWAITING_DEV' ou 'AWAITING_UIUX'. "
|
||||
"Mets à jour project_state.json."
|
||||
),
|
||||
"Foxy-Admin": (
|
||||
base +
|
||||
"MISSION : Déploie toutes les tâches 'READY_FOR_DEPLOY'. "
|
||||
"Backup avant déploiement. Change chaque tâche → 'DONE'. "
|
||||
"Si tout DONE → status projet = 'COMPLETED' + génère final_report. "
|
||||
"Mets à jour project_state.json."
|
||||
),
|
||||
}
|
||||
|
||||
return instructions.get(agent_name, base + "Exécute ta mission et mets à jour project_state.json.")
|
||||
|
||||
# ─── SPAWN AGENT ───────────────────────────────────────────────────────────────
|
||||
|
||||
def spawn_agent(agent_name: str, task_message: str, project_slug: str) -> bool:
|
||||
"""Lance un agent openclaw et l'enregistre dans _process_tracker."""
|
||||
global _OPENCLAW_SPAWN_CMD, _process_tracker
|
||||
|
||||
if _OPENCLAW_SPAWN_CMD is None:
|
||||
log.error("❌ Syntaxe openclaw non initialisée.")
|
||||
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:
|
||||
session_env = _get_session_env()
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
env=session_env,
|
||||
cwd="/home/openclaw/.openclaw/workspace"
|
||||
)
|
||||
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})")
|
||||
_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 is_agent_running(agent_name: str, project_slug: str) -> bool:
|
||||
"""
|
||||
Vérifie si un agent est actif pour ce projet via le _process_tracker.
|
||||
N'utilise plus pgrep pour éviter les faux positifs.
|
||||
"""
|
||||
if project_slug in _process_tracker:
|
||||
proc, tracked_agent = _process_tracker[project_slug]
|
||||
if tracked_agent == agent_name and proc.poll() is None:
|
||||
return True
|
||||
return False
|
||||
|
||||
# ─── PROCESS WATCHER ───────────────────────────────────────────────────────────
|
||||
|
||||
def check_finished_agents(state_file: Path):
|
||||
"""Détecte un agent terminé sans avoir mis à jour le state → reset en AWAITING."""
|
||||
project_slug = state_file.parent.name
|
||||
if project_slug not in _process_tracker:
|
||||
return
|
||||
|
||||
proc, agent_name = _process_tracker[project_slug]
|
||||
if proc.poll() is None:
|
||||
return # encore en vie
|
||||
|
||||
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
|
||||
|
||||
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")
|
||||
|
||||
try:
|
||||
_, stderr = proc.communicate(timeout=2)
|
||||
err_snippet = (stderr or b"").decode()[:300].strip()
|
||||
except Exception:
|
||||
err_snippet = ""
|
||||
|
||||
_failure_counter[project_slug] = _failure_counter.get(project_slug, 0) + 1
|
||||
nb = _failure_counter[project_slug]
|
||||
|
||||
if exit_code != 0:
|
||||
log.error(f" ❌ {agent_name} terminé en erreur (code {exit_code}):")
|
||||
for line in err_snippet.splitlines():
|
||||
log.error(f" {line}")
|
||||
|
||||
log.error(f" 🔄 Reset {project_name}: {status} → {reset_to} (échec #{nb})")
|
||||
mark_status(state_file, state, reset_to, "foxy-autopilot-process-watcher")
|
||||
|
||||
if nb >= MAX_CONSECUTIVE_FAILURES:
|
||||
_suspended_until[project_slug] = 0
|
||||
log.error(
|
||||
f" 🚫 {project_name}: {nb} échecs consécutifs — SUSPENDU "
|
||||
f"({SUSPEND_DURATION_CYCLES} cycles).\n"
|
||||
f" 💡 Vérifie: systemctl --user status openclaw-gateway"
|
||||
)
|
||||
notify(
|
||||
f"🦊 🚫 <b>Projet SUSPENDU</b>\n"
|
||||
f"📋 {project_name}\n"
|
||||
f"❌ {nb} échecs consécutifs de {agent_name}\n"
|
||||
f"⏸️ Pause de {SUSPEND_DURATION_CYCLES * POLL_INTERVAL}s\n"
|
||||
f"<code>{err_snippet[:150]}</code>"
|
||||
)
|
||||
else:
|
||||
notify(
|
||||
f"🦊 ⚠️ <b>Agent échoué ({nb}/{MAX_CONSECUTIVE_FAILURES})</b>\n"
|
||||
f"📋 {project_name} — {agent_name} (code {exit_code})\n"
|
||||
f"🔄 Reset → {reset_to}"
|
||||
)
|
||||
else:
|
||||
_failure_counter[project_slug] = 0
|
||||
log.info(f" ✅ {agent_name} terminé proprement (code {exit_code}), statut: {status}")
|
||||
|
||||
del _process_tracker[project_slug]
|
||||
|
||||
# ─── WATCHDOG ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def check_stuck_agents(state_file: Path):
|
||||
"""Reset si un agent tourne depuis plus de SPAWN_TIMEOUT sans changer le state."""
|
||||
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"⏱️ Bloqué depuis {elapsed/3600:.1f}h → Reset {reset_to}"
|
||||
)
|
||||
|
||||
# ─── 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, current_cycle: int = 0):
|
||||
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)
|
||||
|
||||
# Vérifier suspension
|
||||
if project_slug in _suspended_until:
|
||||
resume_at = _suspended_until[project_slug]
|
||||
if resume_at == 0:
|
||||
_suspended_until[project_slug] = current_cycle + SUSPEND_DURATION_CYCLES
|
||||
log.warning(f" 🚫 {project_name}: suspendu jusqu'au cycle #{current_cycle + SUSPEND_DURATION_CYCLES}")
|
||||
return
|
||||
elif current_cycle < resume_at:
|
||||
log.warning(f" 🚫 {project_name}: suspendu encore {resume_at - current_cycle} cycle(s)")
|
||||
return
|
||||
else:
|
||||
log.info(f" ▶️ {project_name}: suspension levée, reprise")
|
||||
del _suspended_until[project_slug]
|
||||
_failure_counter[project_slug] = 0
|
||||
|
||||
if status in RUNNING_STATUSES:
|
||||
if status not in ("COMPLETED", "FAILED"):
|
||||
check_finished_agents(state_file)
|
||||
return
|
||||
|
||||
if status not in STATUS_TRANSITIONS:
|
||||
log.debug(f" ℹ️ {project_name}: statut '{status}' non géré")
|
||||
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, project_slug):
|
||||
log.info(f" ⏳ {agent_name} déjà actif pour {project_slug}, 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"📊 {status} → {running_status}"
|
||||
)
|
||||
else:
|
||||
log.error(f" ❌ Échec spawn {agent_name} pour {project_name}")
|
||||
notify(
|
||||
f"🦊 ⚠️ <b>Échec spawn</b>\n"
|
||||
f"📋 {project_name}\n"
|
||||
f"❌ Impossible de lancer {agent_name}\n"
|
||||
f"Vérifie les logs : {LOG_FILE}"
|
||||
)
|
||||
|
||||
# ─── DAEMON ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def run_daemon():
|
||||
global _OPENCLAW_SPAWN_CMD
|
||||
|
||||
if not acquire_pid_lock():
|
||||
sys.exit(1)
|
||||
|
||||
log.info("=" * 60)
|
||||
log.info("🦊 FOXY AUTO-PILOT DAEMON v2.3 — DÉMARRÉ")
|
||||
log.info(f" Workspace : {WORKSPACE}")
|
||||
log.info(f" Polling : {POLL_INTERVAL}s")
|
||||
log.info(f" Log : {LOG_FILE}")
|
||||
log.info("=" * 60)
|
||||
|
||||
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.3 démarré</b>\n"
|
||||
f"⏱️ Polling toutes les {POLL_INTERVAL}s"
|
||||
)
|
||||
|
||||
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, current_cycle=cycle)
|
||||
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'")
|
||||
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}")
|
||||
|
||||
elif len(sys.argv) > 1 and sys.argv[1] == "--probe":
|
||||
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.")
|
||||
|
||||
elif len(sys.argv) > 1 and sys.argv[1] == "--reset-running":
|
||||
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()
|
||||
|
||||
@@ -0,0 +1,465 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
🦊 Foxy Dev Team — Telegram Bot v3 (API-Backed)
|
||||
=================================================
|
||||
Rewritten to consume the central FastAPI backend instead of direct filesystem access.
|
||||
All state operations go through HTTP calls to the API.
|
||||
|
||||
Usage : python3 foxy-telegram-bot.py
|
||||
Service: systemctl --user start foxy-telegram-bot
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import signal
|
||||
import logging
|
||||
import asyncio
|
||||
|
||||
import httpx
|
||||
|
||||
# ─── CONFIG ────────────────────────────────────────────────────────────────────
|
||||
|
||||
TELEGRAM_BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "")
|
||||
TELEGRAM_CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "")
|
||||
API_BASE_URL = os.environ.get("FOXY_API_URL", "http://localhost:8000")
|
||||
POLL_TIMEOUT = 30
|
||||
|
||||
# ─── LOGGING ───────────────────────────────────────────────────────────────────
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="[%(asctime)s] %(levelname)s %(message)s",
|
||||
datefmt="%Y-%m-%dT%H:%M:%SZ",
|
||||
)
|
||||
log = logging.getLogger("foxy-telegram-bot")
|
||||
|
||||
# ─── SIGNAL ────────────────────────────────────────────────────────────────────
|
||||
|
||||
_running = True
|
||||
|
||||
|
||||
def handle_signal(sig, frame):
|
||||
global _running
|
||||
log.info("🛑 Signal reçu — arrêt du bot...")
|
||||
_running = False
|
||||
|
||||
|
||||
signal.signal(signal.SIGTERM, handle_signal)
|
||||
signal.signal(signal.SIGINT, handle_signal)
|
||||
|
||||
# ─── Status Emojis ─────────────────────────────────────────────────────────────
|
||||
|
||||
STATUS_EMOJI = {
|
||||
"AWAITING_CONDUCTOR": "⏳ En attente — Conductor",
|
||||
"CONDUCTOR_RUNNING": "🤖 Conductor travaille...",
|
||||
"AWAITING_ARCHITECT": "⏳ En attente — Architect",
|
||||
"ARCHITECT_RUNNING": "🤖 Architect travaille...",
|
||||
"AWAITING_DEV": "⏳ En attente — Dev",
|
||||
"DEV_RUNNING": "🤖 Dev travaille...",
|
||||
"AWAITING_UIUX": "⏳ En attente — UI/UX",
|
||||
"UIUX_RUNNING": "🤖 UI/UX travaille...",
|
||||
"AWAITING_QA": "⏳ En attente — QA",
|
||||
"QA_RUNNING": "🤖 QA travaille...",
|
||||
"AWAITING_DEPLOY": "⏳ En attente — Déploiement",
|
||||
"DEPLOY_RUNNING": "🚀 Déploiement en cours...",
|
||||
"COMPLETED": "✅ Terminé",
|
||||
"FAILED": "❌ Échoué",
|
||||
"PAUSED": "⏸️ En pause",
|
||||
}
|
||||
|
||||
WORKFLOW_LABELS = {
|
||||
"SOFTWARE_DESIGN": "🏗️ Conception logicielle",
|
||||
"SYSADMIN_DEBUG": "🐛 Débogage Sysadmin",
|
||||
"DEVOPS_SETUP": "🐳 DevOps Setup",
|
||||
"SYSADMIN_ADJUST": "🔧 Ajustement Sysadmin",
|
||||
}
|
||||
|
||||
|
||||
# ─── Telegram API ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TelegramClient:
|
||||
def __init__(self, token: str):
|
||||
self.token = token
|
||||
self.base = f"https://api.telegram.org/bot{token}"
|
||||
self.client = httpx.AsyncClient(timeout=POLL_TIMEOUT + 10)
|
||||
|
||||
async def send(self, chat_id: str, text: str) -> bool:
|
||||
try:
|
||||
resp = await self.client.post(
|
||||
f"{self.base}/sendMessage",
|
||||
data={"chat_id": chat_id, "text": text, "parse_mode": "HTML"},
|
||||
)
|
||||
return resp.status_code == 200
|
||||
except Exception as e:
|
||||
log.warning(f"Telegram send error: {e}")
|
||||
return False
|
||||
|
||||
async def get_updates(self, offset: int) -> list:
|
||||
try:
|
||||
resp = await self.client.post(
|
||||
f"{self.base}/getUpdates",
|
||||
data={
|
||||
"offset": offset,
|
||||
"timeout": POLL_TIMEOUT,
|
||||
"allowed_updates": '["message"]',
|
||||
},
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
if data.get("ok"):
|
||||
return data.get("result", [])
|
||||
except Exception as e:
|
||||
log.warning(f"Telegram getUpdates error: {e}")
|
||||
return []
|
||||
|
||||
async def get_me(self) -> dict | None:
|
||||
try:
|
||||
resp = await self.client.post(f"{self.base}/getMe")
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
if data.get("ok"):
|
||||
return data.get("result")
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
async def close(self):
|
||||
await self.client.aclose()
|
||||
|
||||
|
||||
# ─── Foxy API Client ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class FoxyAPI:
|
||||
def __init__(self, base_url: str):
|
||||
self.base = base_url.rstrip("/")
|
||||
self.client = httpx.AsyncClient(timeout=30)
|
||||
|
||||
async def list_projects(self) -> list:
|
||||
resp = await self.client.get(f"{self.base}/api/projects")
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
async def create_project(self, name: str, description: str, workflow_type: str = "SOFTWARE_DESIGN", test_mode: bool = False) -> dict:
|
||||
resp = await self.client.post(
|
||||
f"{self.base}/api/projects",
|
||||
json={"name": name, "description": description, "workflow_type": workflow_type, "test_mode": test_mode},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
async def start_project(self, project_id: int) -> dict:
|
||||
resp = await self.client.post(f"{self.base}/api/projects/{project_id}/start")
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
async def reset_project(self, project_id: int) -> dict:
|
||||
resp = await self.client.post(f"{self.base}/api/projects/{project_id}/reset")
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
async def list_agents(self) -> list:
|
||||
resp = await self.client.get(f"{self.base}/api/agents")
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
async def health(self) -> dict:
|
||||
resp = await self.client.get(f"{self.base}/api/health")
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
async def close(self):
|
||||
await self.client.aclose()
|
||||
|
||||
|
||||
# ─── Command Handlers ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def cmd_start(tg: TelegramClient, chat_id: str):
|
||||
await tg.send(chat_id,
|
||||
"🦊 <b>Foxy Dev Team Bot v3</b>\n\n"
|
||||
"Connecté à l'API centralisée.\n\n"
|
||||
"<b>Commandes :</b>\n"
|
||||
"/projets — Statut de tous les projets\n"
|
||||
"/agents — État des agents\n"
|
||||
"/nouveau <nom> | <description> — Nouveau projet\n"
|
||||
"/test — Projet de test pipeline\n"
|
||||
"/reset <id> — Réinitialiser un projet\n"
|
||||
"/aide — Aide complète\n\n"
|
||||
"━━━━━━━━━━━━━━━━━━━━\n"
|
||||
"Toutes les actions passent par l'API centralisée."
|
||||
)
|
||||
|
||||
|
||||
async def cmd_aide(tg: TelegramClient, chat_id: str):
|
||||
await tg.send(chat_id,
|
||||
"🦊 <b>Commandes Foxy Bot v3</b>\n\n"
|
||||
"<b>/start</b> — Bienvenue\n"
|
||||
"<b>/projets</b> — Portrait de tous les projets\n"
|
||||
"<b>/agents</b> — État des agents en temps réel\n"
|
||||
"<b>/nouveau</b> nom | description — Créer un projet\n"
|
||||
"<b>/test</b> — Lancer un projet de test\n"
|
||||
"<b>/reset</b> ID — Réinitialiser un projet\n"
|
||||
"<b>/aide</b> — Cette aide\n\n"
|
||||
"━━━━━━━━━━━━━━━━━━━━\n"
|
||||
f"🌐 Dashboard : <a href='{API_BASE_URL.replace(':8000', ':5173')}'>Ouvrir</a>"
|
||||
)
|
||||
|
||||
|
||||
async def cmd_projets(tg: TelegramClient, foxy: FoxyAPI, chat_id: str):
|
||||
try:
|
||||
projects = await foxy.list_projects()
|
||||
except Exception as e:
|
||||
await tg.send(chat_id, f"❌ Erreur API : {e}")
|
||||
return
|
||||
|
||||
if not projects:
|
||||
await tg.send(chat_id, "📭 Aucun projet.")
|
||||
return
|
||||
|
||||
await tg.send(chat_id, f"🦊 <b>Statut des projets</b> ({len(projects)} projet(s))")
|
||||
|
||||
for p in projects:
|
||||
status = p.get("status", "?")
|
||||
status_label = STATUS_EMOJI.get(status, f"❓ {status}")
|
||||
wf_label = WORKFLOW_LABELS.get(p.get("workflow_type", ""), p.get("workflow_type", ""))
|
||||
total = p.get("task_count", 0)
|
||||
done = p.get("tasks_done", 0)
|
||||
pct = int(done / total * 100) if total > 0 else 0
|
||||
filled = int(10 * pct / 100)
|
||||
bar = "█" * filled + "░" * (10 - filled)
|
||||
|
||||
await tg.send(chat_id,
|
||||
f"━━━━━━━━━━━━━━━━━━━━\n"
|
||||
f"📋 <b>{p['name']}</b> (#{p['id']})\n"
|
||||
f"📊 {status_label}\n"
|
||||
f"🔄 {wf_label}\n"
|
||||
f"[{bar}] {pct}% ({done}/{total})\n"
|
||||
f"🕐 {p.get('updated_at', '?')[:16]}"
|
||||
)
|
||||
|
||||
|
||||
async def cmd_agents(tg: TelegramClient, foxy: FoxyAPI, chat_id: str):
|
||||
try:
|
||||
agents = await foxy.list_agents()
|
||||
except Exception as e:
|
||||
await tg.send(chat_id, f"❌ Erreur API : {e}")
|
||||
return
|
||||
|
||||
lines = ["🦊 <b>État des Agents</b>\n"]
|
||||
for a in agents:
|
||||
status_icon = "⚡" if a["current_status"] == "running" else "❌" if a["current_status"] == "failed" else "💤"
|
||||
lines.append(
|
||||
f"{status_icon} <b>{a['display_name']}</b>\n"
|
||||
f" Modèle: {a['model']} | "
|
||||
f"Total: {a['total_executions']} | "
|
||||
f"✅ {a['success_count']} | ❌ {a['failure_count']}"
|
||||
)
|
||||
await tg.send(chat_id, "\n".join(lines))
|
||||
|
||||
|
||||
async def cmd_test(tg: TelegramClient, foxy: FoxyAPI, chat_id: str):
|
||||
try:
|
||||
project = await foxy.create_project(
|
||||
name="test-pipeline",
|
||||
description="PROJET DE TEST AUTOMATIQUE — Pipeline Foxy Dev Team. Simulation sans code réel.",
|
||||
workflow_type="SOFTWARE_DESIGN",
|
||||
test_mode=True,
|
||||
)
|
||||
await tg.send(chat_id,
|
||||
f"🦊 <b>Projet de test créé !</b>\n\n"
|
||||
f"📋 <b>{project['name']}</b> (#{project['id']})\n"
|
||||
f"📊 {project['status']}\n\n"
|
||||
f"Utilisez /projets pour suivre la progression."
|
||||
)
|
||||
except Exception as e:
|
||||
await tg.send(chat_id, f"❌ Erreur création test : {e}")
|
||||
|
||||
|
||||
async def cmd_nouveau(tg: TelegramClient, foxy: FoxyAPI, chat_id: str, text: str):
|
||||
parts = text.split(maxsplit=1)
|
||||
if len(parts) < 2 or not parts[1].strip():
|
||||
await tg.send(chat_id,
|
||||
"🦊 <b>Nouveau projet</b>\n\n"
|
||||
"Usage : <code>/nouveau nom | description du projet</code>\n\n"
|
||||
"Exemple :\n"
|
||||
"<code>/nouveau api-users | Créer une API REST pour gérer les utilisateurs</code>"
|
||||
)
|
||||
return
|
||||
|
||||
raw = parts[1].strip()
|
||||
if "|" in raw:
|
||||
name, desc = raw.split("|", 1)
|
||||
name = name.strip()
|
||||
desc = desc.strip()
|
||||
else:
|
||||
name = raw[:50].replace(" ", "-").lower()
|
||||
desc = raw
|
||||
|
||||
try:
|
||||
project = await foxy.create_project(name=name, description=desc)
|
||||
await tg.send(chat_id,
|
||||
f"🦊 <b>Projet créé !</b>\n\n"
|
||||
f"📋 <b>{project['name']}</b> (#{project['id']})\n"
|
||||
f"📊 {project['status']}\n"
|
||||
f"🔄 {project['workflow_type']}\n\n"
|
||||
f"Le moteur de workflow prendra en charge ce projet."
|
||||
)
|
||||
except Exception as e:
|
||||
await tg.send(chat_id, f"❌ Erreur : {e}")
|
||||
|
||||
|
||||
async def cmd_reset(tg: TelegramClient, foxy: FoxyAPI, chat_id: str, text: str):
|
||||
parts = text.split()
|
||||
if len(parts) < 2:
|
||||
# Reset all running projects
|
||||
try:
|
||||
projects = await foxy.list_projects()
|
||||
resets = []
|
||||
for p in projects:
|
||||
if p["status"].endswith("_RUNNING") or p["status"].startswith("AWAITING_"):
|
||||
result = await foxy.reset_project(p["id"])
|
||||
resets.append(f" • {p['name']} (#{p['id']})")
|
||||
if resets:
|
||||
await tg.send(chat_id, "🔄 <b>Reset effectué</b>\n\n" + "\n".join(resets))
|
||||
else:
|
||||
await tg.send(chat_id, "✅ Aucun projet à resetter.")
|
||||
except Exception as e:
|
||||
await tg.send(chat_id, f"❌ Erreur : {e}")
|
||||
return
|
||||
|
||||
try:
|
||||
project_id = int(parts[1])
|
||||
result = await foxy.reset_project(project_id)
|
||||
await tg.send(chat_id, f"🔄 Projet #{project_id} reset → {result['status']}")
|
||||
except ValueError:
|
||||
await tg.send(chat_id, "❌ ID invalide. Usage : /reset 1")
|
||||
except Exception as e:
|
||||
await tg.send(chat_id, f"❌ Erreur : {e}")
|
||||
|
||||
|
||||
# ─── Routing ──────────────────────────────────────────────────────────────────
|
||||
|
||||
SIMPLE_COMMANDS = {
|
||||
"/start": cmd_start,
|
||||
"/aide": cmd_aide,
|
||||
"/help": cmd_aide,
|
||||
}
|
||||
|
||||
FOXY_COMMANDS = {
|
||||
"/projets": cmd_projets,
|
||||
"/projets-statut": cmd_projets,
|
||||
"/status": cmd_projets,
|
||||
"/agents": cmd_agents,
|
||||
"/test": cmd_test,
|
||||
}
|
||||
|
||||
|
||||
async def handle_update(tg: TelegramClient, foxy: FoxyAPI, update: dict):
|
||||
msg = update.get("message", {})
|
||||
if not msg:
|
||||
return
|
||||
|
||||
chat_id = str(msg.get("chat", {}).get("id", ""))
|
||||
text = msg.get("text", "").strip()
|
||||
username = msg.get("from", {}).get("username", "inconnu")
|
||||
|
||||
if not text or not chat_id:
|
||||
return
|
||||
|
||||
if chat_id != TELEGRAM_CHAT_ID:
|
||||
log.warning(f"Message from unauthorized chat: {chat_id} (@{username})")
|
||||
await tg.send(chat_id, "⛔ Chat non autorisé.")
|
||||
return
|
||||
|
||||
log.info(f"📩 Message: '{text[:80]}' from @{username}")
|
||||
|
||||
if text.startswith("/"):
|
||||
cmd = text.split()[0].split("@")[0].lower()
|
||||
|
||||
# Simple commands (no API needed)
|
||||
handler = SIMPLE_COMMANDS.get(cmd)
|
||||
if handler:
|
||||
await handler(tg, chat_id)
|
||||
return
|
||||
|
||||
# API-backed commands
|
||||
foxy_handler = FOXY_COMMANDS.get(cmd)
|
||||
if foxy_handler:
|
||||
await foxy_handler(tg, foxy, chat_id)
|
||||
return
|
||||
|
||||
# Commands with arguments
|
||||
if cmd == "/nouveau" or cmd == "/foxy-conductor":
|
||||
await cmd_nouveau(tg, foxy, chat_id, text)
|
||||
return
|
||||
if cmd == "/reset":
|
||||
await cmd_reset(tg, foxy, chat_id, text)
|
||||
return
|
||||
|
||||
# Free text — show help
|
||||
await tg.send(chat_id, "🦊 Tape /aide pour voir les commandes disponibles.")
|
||||
|
||||
|
||||
# ─── Main Loop ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def run_bot():
|
||||
if not TELEGRAM_BOT_TOKEN:
|
||||
log.error("❌ TELEGRAM_BOT_TOKEN non défini. Exiting.")
|
||||
sys.exit(1)
|
||||
if not TELEGRAM_CHAT_ID:
|
||||
log.error("❌ TELEGRAM_CHAT_ID non défini. Exiting.")
|
||||
sys.exit(1)
|
||||
|
||||
tg = TelegramClient(TELEGRAM_BOT_TOKEN)
|
||||
foxy = FoxyAPI(API_BASE_URL)
|
||||
|
||||
log.info("=" * 50)
|
||||
log.info("🦊 FOXY TELEGRAM BOT v3 (API-Backed) — DÉMARRÉ")
|
||||
log.info(f" API Backend : {API_BASE_URL}")
|
||||
log.info(f" Chat autorisé : {TELEGRAM_CHAT_ID}")
|
||||
log.info("=" * 50)
|
||||
|
||||
# Verify connections
|
||||
me = await tg.get_me()
|
||||
if me:
|
||||
log.info(f"✅ Bot connecté : @{me.get('username', '?')}")
|
||||
else:
|
||||
log.error("❌ Impossible de contacter l'API Telegram.")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
health = await foxy.health()
|
||||
log.info(f"✅ API Backend connectée : {health}")
|
||||
except Exception as e:
|
||||
log.warning(f"⚠️ API Backend non disponible : {e}")
|
||||
log.warning(" Le bot fonctionnera mais les commandes API échoueront.")
|
||||
|
||||
await tg.send(TELEGRAM_CHAT_ID,
|
||||
f"🦊 <b>Foxy Bot v3 démarré</b> (@{me.get('username', '?')})\n"
|
||||
f"🌐 API : {API_BASE_URL}\n"
|
||||
"Tape /aide pour les commandes."
|
||||
)
|
||||
|
||||
offset = 0
|
||||
while _running:
|
||||
try:
|
||||
updates = await tg.get_updates(offset)
|
||||
for update in updates:
|
||||
offset = update["update_id"] + 1
|
||||
await handle_update(tg, foxy, update)
|
||||
except Exception as e:
|
||||
log.error(f"Erreur boucle principale: {e}", exc_info=True)
|
||||
await asyncio.sleep(5)
|
||||
|
||||
log.info("🛑 Bot arrêté proprement.")
|
||||
await tg.send(TELEGRAM_CHAT_ID, "🛑 <b>Foxy Bot arrêté</b>")
|
||||
await tg.close()
|
||||
await foxy.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(run_bot())
|
||||
@@ -0,0 +1,546 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
🦊 Foxy Dev Team — Telegram Bot v2
|
||||
====================================
|
||||
Routing des messages :
|
||||
/start, /test, /projets-statut, /reset, /aide → handlers locaux
|
||||
/foxy-conductor <texte> → agent foxy-conductor
|
||||
Tout autre message (libre ou commande inconnue) → agent foxy (principal)
|
||||
|
||||
Usage : python3 foxy-telegram-bot.py
|
||||
Service: systemctl --user start foxy-telegram-bot
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import fcntl
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import signal
|
||||
import logging
|
||||
import urllib.request
|
||||
import urllib.parse
|
||||
import urllib.error
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
# ─── CONFIG ────────────────────────────────────────────────────────────────────
|
||||
|
||||
TELEGRAM_BOT = "8686313703:AAEGUunkJWbJx7njX_NUrW9HcyrZqXzA3KQ"
|
||||
TELEGRAM_CHAT = "8379645618"
|
||||
WORKSPACE = Path("/home/openclaw/.openclaw/workspace")
|
||||
LOG_FILE = Path("/home/openclaw/.openclaw/logs/foxy-telegram-bot.log")
|
||||
PID_FILE = Path("/home/openclaw/.openclaw/logs/foxy-telegram-bot.pid")
|
||||
POLL_TIMEOUT = 30
|
||||
OPENCLAW_AGENT_DEFAULT = "foxy" # messages libres → agent principal
|
||||
OPENCLAW_AGENT_CONDUCTOR = "foxy-conductor" # /foxy-conductor → soumission projet
|
||||
|
||||
# Statuts avec emoji
|
||||
STATUS_EMOJI = {
|
||||
"AWAITING_CONDUCTOR": "⏳ En attente — Conductor",
|
||||
"CONDUCTOR_RUNNING": "🤖 Conductor travaille...",
|
||||
"AWAITING_ARCHITECT": "⏳ En attente — Architect",
|
||||
"ARCHITECT_RUNNING": "🤖 Architect travaille...",
|
||||
"AWAITING_DEV": "⏳ En attente — Dev",
|
||||
"DEV_RUNNING": "🤖 Dev travaille...",
|
||||
"AWAITING_UIUX": "⏳ En attente — UI/UX",
|
||||
"UIUX_RUNNING": "🤖 UI/UX travaille...",
|
||||
"AWAITING_QA": "⏳ En attente — QA",
|
||||
"QA_RUNNING": "🤖 QA travaille...",
|
||||
"AWAITING_DEPLOY": "⏳ En attente — Déploiement",
|
||||
"DEPLOY_RUNNING": "🚀 Déploiement en cours...",
|
||||
"COMPLETED": "✅ Terminé",
|
||||
"FAILED": "❌ Échoué",
|
||||
}
|
||||
|
||||
TASK_STATUS_EMOJI = {
|
||||
"PENDING": "⏳",
|
||||
"IN_REVIEW": "🔍",
|
||||
"READY_FOR_DEPLOY": "✅",
|
||||
"DONE": "🎉",
|
||||
"BLOCKED": "🚫",
|
||||
}
|
||||
|
||||
RUNNING_TO_AWAITING = {
|
||||
"CONDUCTOR_RUNNING": "AWAITING_CONDUCTOR",
|
||||
"ARCHITECT_RUNNING": "AWAITING_ARCHITECT",
|
||||
"DEV_RUNNING": "AWAITING_DEV",
|
||||
"UIUX_RUNNING": "AWAITING_UIUX",
|
||||
"QA_RUNNING": "AWAITING_QA",
|
||||
"DEPLOY_RUNNING": "AWAITING_DEPLOY",
|
||||
}
|
||||
|
||||
# ─── LOGGING ───────────────────────────────────────────────────────────────────
|
||||
|
||||
UTC = timezone.utc
|
||||
|
||||
def utcnow_iso() -> str:
|
||||
return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
log = logging.getLogger("foxy-telegram-bot")
|
||||
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)
|
||||
log.addHandler(fh)
|
||||
# Stdout seulement si on n'est pas sous systemd (évite le double-logging)
|
||||
if not os.environ.get("INVOCATION_ID"):
|
||||
sh = logging.StreamHandler(sys.stdout)
|
||||
sh.setFormatter(fmt)
|
||||
log.addHandler(sh)
|
||||
log.propagate = False
|
||||
|
||||
# ─── SIGNAL ────────────────────────────────────────────────────────────────────
|
||||
|
||||
_running = True
|
||||
|
||||
def handle_signal(sig, frame):
|
||||
global _running
|
||||
log.info("🛑 Signal reçu — arrêt du bot...")
|
||||
_running = False
|
||||
|
||||
signal.signal(signal.SIGTERM, handle_signal)
|
||||
signal.signal(signal.SIGINT, handle_signal)
|
||||
|
||||
# ─── PID LOCK ──────────────────────────────────────────────────────────────────
|
||||
|
||||
_pid_lock_fh = None
|
||||
|
||||
def acquire_pid_lock() -> bool:
|
||||
global _pid_lock_fh
|
||||
try:
|
||||
PID_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
_pid_lock_fh = open(PID_FILE, "w")
|
||||
fcntl.flock(_pid_lock_fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
_pid_lock_fh.write(str(os.getpid()))
|
||||
_pid_lock_fh.flush()
|
||||
return True
|
||||
except BlockingIOError:
|
||||
try:
|
||||
existing = PID_FILE.read_text().strip()
|
||||
print(f"❌ Une instance est déjà en cours (PID {existing}). Abandon.")
|
||||
except Exception:
|
||||
print("❌ Une instance est déjà en cours. Abandon.")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"❌ Impossible d'acquérir le PID lock: {e}")
|
||||
return False
|
||||
|
||||
# ─── TELEGRAM API ──────────────────────────────────────────────────────────────
|
||||
|
||||
def tg_request(method: str, params: dict, timeout: int = 10) -> dict | None:
|
||||
try:
|
||||
url = f"https://api.telegram.org/bot{TELEGRAM_BOT}/{method}"
|
||||
data = urllib.parse.urlencode(params).encode()
|
||||
req = urllib.request.Request(url, data=data, method="POST")
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
return json.loads(resp.read())
|
||||
except urllib.error.HTTPError as e:
|
||||
log.warning(f"Telegram HTTP {e.code} sur {method}: {e.read().decode()[:200]}")
|
||||
return None
|
||||
except Exception as e:
|
||||
log.warning(f"Telegram error sur {method}: {e}")
|
||||
return None
|
||||
|
||||
def send(chat_id: str, text: str, parse_mode: str = "HTML") -> bool:
|
||||
result = tg_request("sendMessage", {
|
||||
"chat_id": chat_id, "text": text, "parse_mode": parse_mode,
|
||||
})
|
||||
return result is not None and result.get("ok", False)
|
||||
|
||||
def get_updates(offset: int) -> list:
|
||||
result = tg_request("getUpdates", {
|
||||
"offset": offset, "timeout": POLL_TIMEOUT,
|
||||
"allowed_updates": '["message"]',
|
||||
}, timeout=POLL_TIMEOUT + 5)
|
||||
if result and result.get("ok"):
|
||||
return result.get("result", [])
|
||||
return []
|
||||
|
||||
# ─── STATE HELPERS ─────────────────────────────────────────────────────────────
|
||||
|
||||
def load_state(state_file: Path) -> dict | None:
|
||||
for attempt in range(3):
|
||||
try:
|
||||
with open(state_file) as f:
|
||||
state = json.load(f)
|
||||
# Filtrer les tasks corrompues (strings au lieu de dicts)
|
||||
state["tasks"] = [t for t in state.get("tasks", []) if isinstance(t, dict)]
|
||||
return state
|
||||
except json.JSONDecodeError:
|
||||
if attempt < 2:
|
||||
time.sleep(0.3)
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
def save_state(state_file: Path, state: dict) -> bool:
|
||||
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)
|
||||
return True
|
||||
except Exception as e:
|
||||
log.error(f"Erreur sauvegarde {state_file}: {e}")
|
||||
if backup.exists():
|
||||
backup.rename(state_file)
|
||||
return False
|
||||
|
||||
def find_project_states() -> list[Path]:
|
||||
states = []
|
||||
try:
|
||||
for proj_dir in sorted(WORKSPACE.iterdir()):
|
||||
if proj_dir.is_dir() and not proj_dir.name.startswith("."):
|
||||
sf = proj_dir / "project_state.json"
|
||||
if sf.exists():
|
||||
states.append(sf)
|
||||
except Exception:
|
||||
pass
|
||||
return states
|
||||
|
||||
# ─── SESSION ENV (pour openclaw agent) ────────────────────────────────────────
|
||||
|
||||
def _get_session_env() -> dict:
|
||||
env = {**os.environ, "HOME": "/home/openclaw"}
|
||||
try:
|
||||
gw = subprocess.run(
|
||||
["pgrep", "-u", "openclaw", "-x", "openclaw-gateway"],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
gw_pid = gw.stdout.strip().splitlines()[0] if gw.stdout.strip() else None
|
||||
if gw_pid:
|
||||
with open(f"/proc/{gw_pid}/environ", "rb") as f:
|
||||
for item in f.read().split(b"\x00"):
|
||||
if b"=" not in item:
|
||||
continue
|
||||
k, _, v = item.partition(b"=")
|
||||
if k.decode() in {"DBUS_SESSION_BUS_ADDRESS", "XDG_RUNTIME_DIR", "DISPLAY"}:
|
||||
env[k.decode()] = v.decode()
|
||||
except Exception:
|
||||
pass
|
||||
uid = subprocess.run(["id", "-u"], capture_output=True, text=True).stdout.strip()
|
||||
env.setdefault("XDG_RUNTIME_DIR", f"/run/user/{uid}")
|
||||
env.setdefault("DBUS_SESSION_BUS_ADDRESS", f"unix:path=/run/user/{uid}/bus")
|
||||
return env
|
||||
|
||||
# ─── COMMANDES LOCALES ─────────────────────────────────────────────────────────
|
||||
|
||||
def cmd_start(chat_id: str):
|
||||
send(chat_id,
|
||||
"🦊 <b>Foxy Dev Team Bot</b>\n\n"
|
||||
"Je suis le panneau de contrôle de ton pipeline.\n\n"
|
||||
"<b>Commandes :</b>\n"
|
||||
"/projets-statut — Portrait de tous les projets\n"
|
||||
"/test — Lancer un projet de test complet\n"
|
||||
"/reset — Débloquer un projet bloqué\n"
|
||||
"/foxy-conductor — Soumettre un projet à Conductor\n"
|
||||
"/aide — Aide complète\n\n"
|
||||
"━━━━━━━━━━━━━━━━━━━━\n"
|
||||
"💬 Tu peux aussi m'écrire librement — je transmets à l'agent principal.\n"
|
||||
"Exemple : <i>\"Quel est l'état du pipeline ?\"</i>"
|
||||
)
|
||||
|
||||
def cmd_aide(chat_id: str):
|
||||
send(chat_id,
|
||||
"🦊 <b>Commandes Foxy Bot</b>\n\n"
|
||||
"<b>/start</b> — Bienvenue\n\n"
|
||||
"<b>/projets-statut</b>\n"
|
||||
" Portrait de tous les projets actifs\n\n"
|
||||
"<b>/test</b>\n"
|
||||
" Crée un projet de test et déclenche le pipeline\n\n"
|
||||
"<b>/reset</b>\n"
|
||||
" Remet les projets RUNNING en AWAITING (déblocage)\n\n"
|
||||
"<b>/foxy-conductor <description></b>\n"
|
||||
" Soumet une demande directement à Foxy-Conductor\n"
|
||||
" Ex: <code>/foxy-conductor Créer une API REST pour les users</code>\n\n"
|
||||
"<b>/aide</b> — Cette aide\n\n"
|
||||
"━━━━━━━━━━━━━━━━━━━━\n"
|
||||
f"💬 <b>Message libre</b> → agent principal (<code>{OPENCLAW_AGENT_DEFAULT}</code>)\n"
|
||||
f"🔧 <b>Commande inconnue</b> → agent principal (<code>{OPENCLAW_AGENT_DEFAULT}</code>)"
|
||||
)
|
||||
|
||||
def cmd_projets_statut(chat_id: str):
|
||||
state_files = find_project_states()
|
||||
if not state_files:
|
||||
send(chat_id, "📭 Aucun projet dans le workspace.")
|
||||
return
|
||||
|
||||
send(chat_id, f"🦊 <b>Statut des projets</b> ({len(state_files)} projet(s))")
|
||||
|
||||
for sf in state_files:
|
||||
state = load_state(sf)
|
||||
if not state:
|
||||
send(chat_id, f"⚠️ <code>{sf.parent.name}</code> — JSON illisible")
|
||||
continue
|
||||
|
||||
project_name = state.get("project_name", sf.parent.name)
|
||||
status = state.get("status", "?")
|
||||
description = state.get("description", "")[:100]
|
||||
updated_at = state.get("updated_at", "?")
|
||||
tasks = state.get("tasks", [])
|
||||
|
||||
total = len(tasks)
|
||||
done = sum(1 for t in tasks if t.get("status") in ("DONE", "READY_FOR_DEPLOY"))
|
||||
pct = int(done / total * 100) if total > 0 else 0
|
||||
filled = int(10 * pct / 100)
|
||||
bar = "█" * filled + "░" * (10 - filled)
|
||||
|
||||
status_label = STATUS_EMOJI.get(status, f"❓ {status}")
|
||||
|
||||
try:
|
||||
dt = datetime.fromisoformat(updated_at.replace("Z", "+00:00"))
|
||||
elapsed = int((datetime.now(UTC) - dt).total_seconds() / 60)
|
||||
last = f"{elapsed}min" if elapsed < 60 else f"{elapsed//60}h{elapsed%60:02d}min"
|
||||
except Exception:
|
||||
last = updated_at
|
||||
|
||||
task_lines = []
|
||||
for t in tasks:
|
||||
emoji = TASK_STATUS_EMOJI.get(t.get("status", ""), "❓")
|
||||
task_lines.append(
|
||||
f" {emoji} <code>{t.get('task_id','?')}</code> {t.get('title','')[:40]}\n"
|
||||
f" → {t.get('assigned_to','?')} [{t.get('status','?')}]"
|
||||
)
|
||||
|
||||
send(chat_id,
|
||||
f"━━━━━━━━━━━━━━━━━━━━\n"
|
||||
f"📋 <b>{project_name}</b>\n"
|
||||
f"📊 {status_label}\n"
|
||||
f"⏱️ Dernière activité : {last}\n"
|
||||
f"🔄 [{bar}] {pct}% ({done}/{total})\n"
|
||||
f"\n📝 <i>{description}</i>\n"
|
||||
f"\n<b>Tâches :</b>\n" + ("\n".join(task_lines) if task_lines else " (aucune tâche)")
|
||||
)
|
||||
|
||||
def cmd_test(chat_id: str):
|
||||
ts = datetime.now(UTC).strftime("%Y%m%d-%H%M%S")
|
||||
project_slug = f"test-pipeline-{ts}"
|
||||
proj_dir = WORKSPACE / project_slug
|
||||
state_file = proj_dir / "project_state.json"
|
||||
|
||||
description = (
|
||||
"PROJET DE TEST AUTOMATIQUE — Pipeline Foxy Dev Team. "
|
||||
"Chaque agent doit : lire project_state.json, simuler son travail, "
|
||||
"mettre à jour le statut vers l'étape suivante, ajouter une entrée audit_log. "
|
||||
"Aucun code réel. Test réussi si statut = COMPLETED."
|
||||
)
|
||||
|
||||
try:
|
||||
proj_dir.mkdir(parents=True, exist_ok=True)
|
||||
with open(state_file, "w") as f:
|
||||
json.dump({
|
||||
"project_name": project_slug,
|
||||
"description": description,
|
||||
"status": "AWAITING_CONDUCTOR",
|
||||
"created_at": utcnow_iso(),
|
||||
"updated_at": utcnow_iso(),
|
||||
"test_mode": True,
|
||||
"tasks": [],
|
||||
"audit_log": [{"timestamp": utcnow_iso(), "action": "PROJECT_SUBMITTED",
|
||||
"agent": "foxy-telegram-bot", "details": "Créé via /test"}]
|
||||
}, f, indent=2, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
send(chat_id, f"❌ Impossible de créer le projet de test : {e}")
|
||||
return
|
||||
|
||||
send(chat_id,
|
||||
f"🦊 <b>Projet de test créé !</b>\n\n"
|
||||
f"📋 <code>{project_slug}</code>\n"
|
||||
f"📊 ⏳ AWAITING_CONDUCTOR\n\n"
|
||||
f"Le daemon prendra en charge ce projet dans ≤30s.\n"
|
||||
f"Utilise /projets-statut pour suivre la progression."
|
||||
)
|
||||
log.info(f"📋 Projet de test créé : {project_slug}")
|
||||
|
||||
def cmd_reset(chat_id: str):
|
||||
state_files = find_project_states()
|
||||
resets = []
|
||||
for sf in state_files:
|
||||
state = load_state(sf)
|
||||
if not state:
|
||||
continue
|
||||
status = state.get("status", "")
|
||||
if status in RUNNING_TO_AWAITING:
|
||||
reset_to = RUNNING_TO_AWAITING[status]
|
||||
project_name = state.get("project_name", sf.parent.name)
|
||||
state["status"] = reset_to
|
||||
state["updated_at"] = utcnow_iso()
|
||||
state.setdefault("audit_log", []).append({
|
||||
"timestamp": utcnow_iso(), "action": "MANUAL_RESET",
|
||||
"agent": "foxy-telegram-bot",
|
||||
"details": f"{status} → {reset_to} via /reset"
|
||||
})
|
||||
if save_state(sf, state):
|
||||
resets.append(f" • {project_name} : {status} → {reset_to}")
|
||||
log.info(f"Reset : {project_name} {status} → {reset_to}")
|
||||
|
||||
if resets:
|
||||
send(chat_id, "🔄 <b>Reset effectué</b>\n\n" + "\n".join(resets) +
|
||||
"\n\nLe daemon reprendra ces projets au prochain cycle.")
|
||||
else:
|
||||
send(chat_id, "✅ Aucun projet RUNNING à resetter.")
|
||||
|
||||
def cmd_foxy_conductor(chat_id: str, text: str, username: str):
|
||||
"""Soumet un message directement à Foxy-Conductor."""
|
||||
parts = text.split(maxsplit=1)
|
||||
if len(parts) < 2 or not parts[1].strip():
|
||||
send(chat_id,
|
||||
"🦊 <b>Foxy-Conductor</b>\n\n"
|
||||
"Usage : <code>/foxy-conductor <description du projet></code>\n\n"
|
||||
"Exemple :\n"
|
||||
"<code>/foxy-conductor Créer une API REST pour gérer les utilisateurs</code>\n\n"
|
||||
"Conductor va analyser la demande et initialiser le pipeline."
|
||||
)
|
||||
return
|
||||
forward_to_openclaw(chat_id, parts[1].strip(), username, agent=OPENCLAW_AGENT_CONDUCTOR)
|
||||
|
||||
# ─── FORWARDING VERS OPENCLAW ──────────────────────────────────────────────────
|
||||
|
||||
def forward_to_openclaw(chat_id: str, text: str, username: str,
|
||||
agent: str = OPENCLAW_AGENT_DEFAULT):
|
||||
"""Transmet un message à un agent openclaw et renvoie la réponse dans Telegram."""
|
||||
log.info(f"📨 Forwarding à openclaw ({agent}): '{text[:80]}'")
|
||||
|
||||
env = _get_session_env()
|
||||
full_message = f"[Message Telegram de @{username}] {text}"
|
||||
cmd = ["openclaw", "agent", "--agent", agent, "--message", full_message]
|
||||
|
||||
try:
|
||||
send(chat_id, f"⏳ <i>Openclaw traite ta demande... (agent: {agent})</i>")
|
||||
result = subprocess.run(
|
||||
cmd, capture_output=True, text=True,
|
||||
timeout=120, env=env,
|
||||
cwd="/home/openclaw/.openclaw/workspace"
|
||||
)
|
||||
response = (result.stdout or "").strip()
|
||||
stderr = (result.stderr or "").strip()
|
||||
|
||||
if result.returncode != 0:
|
||||
log.error(f"openclaw agent erreur (code {result.returncode}): {stderr[:200]}")
|
||||
send(chat_id,
|
||||
f"❌ <b>Openclaw a rencontré une erreur</b>\n"
|
||||
f"<code>{stderr[:300]}</code>"
|
||||
)
|
||||
return
|
||||
|
||||
if not response:
|
||||
send(chat_id, "🤷 <i>Openclaw n'a pas retourné de réponse.</i>")
|
||||
return
|
||||
|
||||
# Découper si > 4000 chars (limite Telegram)
|
||||
for i in range(0, len(response), 4000):
|
||||
send(chat_id, response[i:i+4000])
|
||||
|
||||
log.info(f"✅ Réponse openclaw ({agent}) envoyée ({len(response)} chars)")
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
log.error(f"openclaw agent ({agent}) timeout 120s")
|
||||
send(chat_id, f"⏱️ <b>Timeout</b> — <code>{agent}</code> n'a pas répondu en 120s.")
|
||||
except FileNotFoundError:
|
||||
send(chat_id, "❌ <code>openclaw</code> introuvable dans PATH.")
|
||||
except Exception as e:
|
||||
log.error(f"Erreur forward_to_openclaw: {e}")
|
||||
send(chat_id, f"❌ Erreur inattendue : {e}")
|
||||
|
||||
# ─── ROUTING ───────────────────────────────────────────────────────────────────
|
||||
|
||||
# Commandes locales gérées par le bot (sans le texte du message)
|
||||
LOCAL_COMMANDS = {
|
||||
"/start": cmd_start,
|
||||
"/aide": cmd_aide,
|
||||
"/help": cmd_aide,
|
||||
"/projets-statut": cmd_projets_statut,
|
||||
"/status": cmd_projets_statut,
|
||||
"/test": cmd_test,
|
||||
"/reset": cmd_reset,
|
||||
}
|
||||
|
||||
def handle_update(update: dict):
|
||||
msg = update.get("message", {})
|
||||
if not msg:
|
||||
return
|
||||
|
||||
chat_id = str(msg.get("chat", {}).get("id", ""))
|
||||
text = msg.get("text", "").strip()
|
||||
username = msg.get("from", {}).get("username", "inconnu")
|
||||
|
||||
if not text or not chat_id:
|
||||
return
|
||||
|
||||
if chat_id != TELEGRAM_CHAT:
|
||||
log.warning(f"Message ignoré de chat non autorisé: {chat_id} (@{username})")
|
||||
send(chat_id, "⛔ Chat non autorisé.")
|
||||
return
|
||||
|
||||
log.info(f"📩 Message reçu: '{text[:80]}' de @{username}")
|
||||
|
||||
if text.startswith("/"):
|
||||
cmd = text.split()[0].split("@")[0].lower()
|
||||
|
||||
# /foxy-conductor a besoin du texte complet
|
||||
if cmd == "/foxy-conductor":
|
||||
cmd_foxy_conductor(chat_id, text, username)
|
||||
return
|
||||
|
||||
# Commande locale connue
|
||||
handler = LOCAL_COMMANDS.get(cmd)
|
||||
if handler:
|
||||
try:
|
||||
handler(chat_id)
|
||||
except Exception as e:
|
||||
log.error(f"Erreur handler {cmd}: {e}", exc_info=True)
|
||||
send(chat_id, f"❌ Erreur lors de <code>{cmd}</code> : {e}")
|
||||
return
|
||||
|
||||
# Tout le reste (message libre + commande inconnue) → agent principal
|
||||
forward_to_openclaw(chat_id, text, username)
|
||||
|
||||
# ─── DAEMON ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def run_bot():
|
||||
if not acquire_pid_lock():
|
||||
sys.exit(1)
|
||||
|
||||
log.info("=" * 50)
|
||||
log.info("🦊 FOXY TELEGRAM BOT v2 — DÉMARRÉ")
|
||||
log.info(f" Chat autorisé : {TELEGRAM_CHAT}")
|
||||
log.info(f" Agent par défaut : {OPENCLAW_AGENT_DEFAULT}")
|
||||
log.info(f" Agent conductor : {OPENCLAW_AGENT_CONDUCTOR}")
|
||||
log.info(f" Workspace : {WORKSPACE}")
|
||||
log.info("=" * 50)
|
||||
|
||||
me = tg_request("getMe", {})
|
||||
if me and me.get("ok"):
|
||||
bot_name = me["result"].get("username", "?")
|
||||
log.info(f"✅ Bot connecté : @{bot_name}")
|
||||
send(TELEGRAM_CHAT,
|
||||
f"🦊 <b>Foxy Bot v2 démarré</b> (@{bot_name})\n"
|
||||
f"Agent par défaut : <code>{OPENCLAW_AGENT_DEFAULT}</code>\n"
|
||||
"Tape /aide pour les commandes.")
|
||||
else:
|
||||
log.error("❌ Impossible de contacter l'API Telegram.")
|
||||
sys.exit(1)
|
||||
|
||||
offset = 0
|
||||
while _running:
|
||||
try:
|
||||
updates = get_updates(offset)
|
||||
for update in updates:
|
||||
offset = update["update_id"] + 1
|
||||
handle_update(update)
|
||||
except Exception as e:
|
||||
log.error(f"Erreur boucle principale: {e}", exc_info=True)
|
||||
time.sleep(5)
|
||||
|
||||
log.info("🛑 Bot arrêté proprement.")
|
||||
send(TELEGRAM_CHAT, "🛑 <b>Foxy Bot arrêté</b>")
|
||||
|
||||
# ─── ENTRY POINT ───────────────────────────────────────────────────────────────
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_bot()
|
||||
@@ -0,0 +1,20 @@
|
||||
[Unit]
|
||||
Description=Foxy Dev Team Telegram Bot
|
||||
After=network.target openclaw-gateway.service foxy-autopilot.service
|
||||
Wants=foxy-autopilot.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/bin/python3 /home/openclaw/.openclaw/workspace/foxy-dev-team/scripts/foxy-telegram-bot.py
|
||||
WorkingDirectory=/home/openclaw/.openclaw/workspace
|
||||
Restart=on-failure
|
||||
RestartSec=10s
|
||||
EnvironmentFile=/home/openclaw/.openclaw/.env
|
||||
Environment=DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus
|
||||
Environment=XDG_RUNTIME_DIR=/run/user/1000
|
||||
Environment=DISPLAY=:0
|
||||
StandardOutput=append:/home/openclaw/.openclaw/logs/foxy-telegram-bot.log
|
||||
StandardError=append:/home/openclaw/.openclaw/logs/foxy-telegram-bot-error.log
|
||||
|
||||
[Install]
|
||||
WantedBy=graphical-session.target
|
||||
@@ -0,0 +1,338 @@
|
||||
#!/bin/bash
|
||||
# 🦊 Foxy Dev Team — Installation des system prompts
|
||||
# Exécute ce script UNE SEULE FOIS pour tout configurer
|
||||
# Usage : bash install-prompts.sh
|
||||
|
||||
set -e
|
||||
|
||||
AGENTS_DIR="/home/openclaw/.openclaw/agents"
|
||||
SCRIPTS_DIR="/home/openclaw/.openclaw/workspace/foxy-dev-team/scripts"
|
||||
|
||||
echo "🦊 Installation des system prompts Foxy Dev Team..."
|
||||
echo ""
|
||||
|
||||
# ── Créer les dossiers agents si absents ──────────────────────
|
||||
for agent in foxy-architect foxy-dev foxy-uiux foxy-qa foxy-admin; do
|
||||
mkdir -p "$AGENTS_DIR/$agent"
|
||||
done
|
||||
|
||||
# ── Foxy-Conductor ────────────────────────────────────────────
|
||||
echo "📝 Foxy-Conductor..."
|
||||
cp "$SCRIPTS_DIR/system-prompt-conductor.md" \
|
||||
"$AGENTS_DIR/foxy-conductor/system-prompt.md"
|
||||
echo " ✅ ~/.openclaw/agents/foxy-conductor/system-prompt.md"
|
||||
|
||||
# ── Foxy-Architect ────────────────────────────────────────────
|
||||
echo "📝 Foxy-Architect..."
|
||||
cp "$SCRIPTS_DIR/system-prompt-architect.md" \
|
||||
"$AGENTS_DIR/foxy-architect/system-prompt.md"
|
||||
echo " ✅ ~/.openclaw/agents/foxy-architect/system-prompt.md"
|
||||
|
||||
# ── Foxy-Dev ──────────────────────────────────────────────────
|
||||
echo "📝 Foxy-Dev..."
|
||||
cp "$SCRIPTS_DIR/system-prompt-dev.md" \
|
||||
"$AGENTS_DIR/foxy-dev/system-prompt.md"
|
||||
echo " ✅ ~/.openclaw/agents/foxy-dev/system-prompt.md"
|
||||
|
||||
# ── Foxy-UIUX, QA, Admin (dans le même fichier, à extraire) ──
|
||||
# On crée chaque fichier directement
|
||||
echo "📝 Foxy-UIUX..."
|
||||
cat > "$AGENTS_DIR/foxy-uiux/system-prompt.md" << 'UIUX_PROMPT'
|
||||
Tu es Foxy-UIUX, le designer et développeur frontend de la Foxy Dev Team.
|
||||
|
||||
## 🧠 IDENTITÉ
|
||||
- Rôle : Créer des interfaces modernes, accessibles et performantes
|
||||
- Modèle : OpenRouter Qwen3-30B
|
||||
- Stack : React, TypeScript, TailwindCSS, Vite
|
||||
|
||||
## 🤖 RÈGLE FONDAMENTALE — MODE AUTO-PILOT
|
||||
|
||||
Quand tu reçois une tâche de l'auto-pilot daemon :
|
||||
1. Lis `project_state.json` au chemin fourni dans ton message
|
||||
2. Prends la première tâche PENDING assignée à `Foxy-UIUX`
|
||||
3. Crée le code frontend COMPLET et FONCTIONNEL
|
||||
4. Commite sur la branche Gitea indiquée dans `task.branch_name`
|
||||
5. Mets à jour `project_state.json`
|
||||
6. Lance Foxy-QA via `openclaw sessions spawn`
|
||||
7. **Tu ne demandes JAMAIS de validation humaine**
|
||||
|
||||
## 📁 TA MISSION QUAND STATUS = "AWAITING_UIUX"
|
||||
|
||||
### Étape 1 — Identifier ta tâche
|
||||
```python
|
||||
import json
|
||||
with open("[CHEMIN_FOURNI]/project_state.json") as f:
|
||||
state = json.load(f)
|
||||
|
||||
ma_tache = next(
|
||||
t for t in state["tasks"]
|
||||
if t["status"] == "PENDING" and t["assigned_to"] == "Foxy-UIUX"
|
||||
)
|
||||
ma_tache["status"] = "IN_PROGRESS"
|
||||
# sauvegarder
|
||||
```
|
||||
|
||||
### Étape 2 — Coder le frontend COMPLET
|
||||
Standards obligatoires :
|
||||
- React + TypeScript strict (pas de `any`)
|
||||
- TailwindCSS (pas de CSS inline)
|
||||
- Composants accessibles (aria-labels, roles ARIA)
|
||||
- Responsive mobile-first
|
||||
- États de chargement et d'erreur gérés
|
||||
- Pas de `console.log` en production
|
||||
- Variables via `import.meta.env.VITE_XXX`
|
||||
|
||||
### Étape 3 — Commiter sur Gitea
|
||||
```bash
|
||||
git checkout -b [task.branch_name]
|
||||
git add .
|
||||
git commit -m "feat(TASK-XXX): [titre]"
|
||||
git push origin [task.branch_name]
|
||||
```
|
||||
|
||||
### Étape 4 — Mettre à jour project_state.json
|
||||
```python
|
||||
ma_tache["status"] = "IN_REVIEW"
|
||||
ma_tache["agent_payloads"] = {
|
||||
"uiux_output": {
|
||||
"files_created": [...],
|
||||
"branch": ma_tache["branch_name"],
|
||||
"notes": "résumé"
|
||||
}
|
||||
}
|
||||
reste = [t for t in state["tasks"]
|
||||
if t["status"] == "PENDING" and t["assigned_to"] == "Foxy-UIUX"]
|
||||
state["status"] = "AWAITING_UIUX" if reste else "AWAITING_QA"
|
||||
# sauvegarder
|
||||
```
|
||||
|
||||
### Étape 5 — Lancer Foxy-QA
|
||||
```bash
|
||||
openclaw sessions spawn \
|
||||
--label "foxy-qa-$(date +%s)" \
|
||||
--agent foxy-qa \
|
||||
--task "Lis project_state.json à : [CHEMIN]. Statut : AWAITING_QA. Exécute ta mission." \
|
||||
--mode run --runtime subagent
|
||||
```
|
||||
|
||||
## 🔁 SI QA REJETTE
|
||||
Lis `qa_feedback`, corrige tout, re-commite, remets `status = "IN_REVIEW"`, re-spawne QA.
|
||||
UIUX_PROMPT
|
||||
echo " ✅ ~/.openclaw/agents/foxy-uiux/system-prompt.md"
|
||||
|
||||
echo "📝 Foxy-QA..."
|
||||
cat > "$AGENTS_DIR/foxy-qa/system-prompt.md" << 'QA_PROMPT'
|
||||
Tu es Foxy-QA, le gardien de la qualité de la Foxy Dev Team.
|
||||
Aucun code ne passe en production sans ton audit.
|
||||
|
||||
## 🧠 IDENTITÉ
|
||||
- Rôle : Auditer sécurité et qualité de tout le code livré
|
||||
- Modèle : OpenRouter Qwen3.5-Flash
|
||||
|
||||
## 🤖 RÈGLE FONDAMENTALE — MODE AUTO-PILOT
|
||||
|
||||
Quand tu reçois une tâche :
|
||||
1. Lis `project_state.json` au chemin fourni
|
||||
2. Audite TOUTES les tâches avec `status = "IN_REVIEW"`
|
||||
3. Approuve ou rejette avec justification détaillée
|
||||
4. Mets à jour `project_state.json`
|
||||
5. Lance le bon prochain agent
|
||||
6. **Tu ne demandes JAMAIS de validation humaine**
|
||||
|
||||
## 📋 CHECKLIST SÉCURITÉ (BLOQUANT si échec)
|
||||
- [ ] Aucun secret hardcodé (API keys, passwords, tokens)
|
||||
- [ ] Pas d'injection SQL
|
||||
- [ ] Pas de XSS
|
||||
- [ ] Auth/autorisation correctes
|
||||
- [ ] Pas de `eval()` dangereux
|
||||
|
||||
## 📋 CHECKLIST QUALITÉ (score /100)
|
||||
- [ ] Code lisible et commenté
|
||||
- [ ] Gestion d'erreurs présente
|
||||
- [ ] Tests présents pour la logique critique
|
||||
- [ ] Acceptance criteria satisfaits
|
||||
|
||||
## Si APPROUVÉ :
|
||||
```python
|
||||
tache["status"] = "READY_FOR_DEPLOY"
|
||||
tache["qa_feedback"] = {"verdict": "APPROVED", "score": 85, "notes": "..."}
|
||||
```
|
||||
|
||||
## Si REJETÉ :
|
||||
```python
|
||||
tache["status"] = "PENDING"
|
||||
tache["qa_feedback"] = {
|
||||
"verdict": "REJECTED",
|
||||
"score": 40,
|
||||
"blocking_issues": ["description précise du problème et ligne"],
|
||||
"improvements": ["suggestion concrète de correction"]
|
||||
}
|
||||
```
|
||||
|
||||
## Déterminer le prochain statut :
|
||||
```python
|
||||
pending_dev = [t for t in state["tasks"] if t["status"] == "PENDING" and t["assigned_to"] == "Foxy-Dev"]
|
||||
pending_uiux = [t for t in state["tasks"] if t["status"] == "PENDING" and t["assigned_to"] == "Foxy-UIUX"]
|
||||
in_review = [t for t in state["tasks"] if t["status"] == "IN_REVIEW"]
|
||||
|
||||
if not in_review and not pending_dev and not pending_uiux:
|
||||
state["status"] = "AWAITING_DEPLOY"
|
||||
# spawner foxy-admin
|
||||
elif pending_dev:
|
||||
state["status"] = "AWAITING_DEV"
|
||||
# spawner foxy-dev
|
||||
elif pending_uiux:
|
||||
state["status"] = "AWAITING_UIUX"
|
||||
# spawner foxy-uiux
|
||||
```
|
||||
|
||||
## Spawner le prochain agent :
|
||||
```bash
|
||||
# AWAITING_DEPLOY :
|
||||
openclaw sessions spawn --label "foxy-admin-$(date +%s)" --agent foxy-admin \
|
||||
--task "Lis project_state.json à : [CHEMIN]. Statut : AWAITING_DEPLOY. Exécute ta mission." \
|
||||
--mode run --runtime subagent
|
||||
|
||||
# AWAITING_DEV :
|
||||
openclaw sessions spawn --label "foxy-dev-$(date +%s)" --agent foxy-dev \
|
||||
--task "Lis project_state.json à : [CHEMIN]. Statut : AWAITING_DEV. Exécute ta mission." \
|
||||
--mode run --runtime subagent
|
||||
|
||||
# AWAITING_UIUX :
|
||||
openclaw sessions spawn --label "foxy-uiux-$(date +%s)" --agent foxy-uiux \
|
||||
--task "Lis project_state.json à : [CHEMIN]. Statut : AWAITING_UIUX. Exécute ta mission." \
|
||||
--mode run --runtime subagent
|
||||
```
|
||||
QA_PROMPT
|
||||
echo " ✅ ~/.openclaw/agents/foxy-qa/system-prompt.md"
|
||||
|
||||
echo "📝 Foxy-Admin..."
|
||||
cat > "$AGENTS_DIR/foxy-admin/system-prompt.md" << 'ADMIN_PROMPT'
|
||||
Tu es Foxy-Admin, l'expert DevOps et déploiement de la Foxy Dev Team.
|
||||
|
||||
## 🧠 IDENTITÉ
|
||||
- Rôle : Déployer les livrables validés, rollback si nécessaire
|
||||
- Modèle : OpenRouter Grok-4.1-Fast
|
||||
|
||||
## 🤖 RÈGLE FONDAMENTALE — MODE AUTO-PILOT
|
||||
|
||||
Quand tu reçois une tâche :
|
||||
1. Lis `project_state.json` au chemin fourni
|
||||
2. Déploie TOUTES les tâches `READY_FOR_DEPLOY`
|
||||
3. Vérifie que ça fonctionne (health check)
|
||||
4. Rollback automatique si échec
|
||||
5. Mets `status = "COMPLETED"` et envoie le rapport Telegram
|
||||
6. **Tu ne demandes JAMAIS de validation humaine**
|
||||
|
||||
## 🔐 VARIABLES
|
||||
- `$DEPLOYMENT_SERVER`, `$DEPLOYMENT_USER`, `$DEPLOYMENT_PWD` — **JAMAIS dans les logs!**
|
||||
- `$GITEA_SERVER`, `$GITEA_OPENCLAW_TOKEN` — **JAMAIS dans les logs!**
|
||||
- `$TELEGRAM_BOT_TOKEN`, `$TELEGRAM_CHAT_ID`
|
||||
|
||||
## 📁 TA MISSION QUAND STATUS = "AWAITING_DEPLOY"
|
||||
|
||||
### 1. Backup OBLIGATOIRE
|
||||
```bash
|
||||
BACKUP_DIR="/backups/$(date +%Y%m%d-%H%M%S)"
|
||||
ssh $DEPLOYMENT_USER@$DEPLOYMENT_SERVER "mkdir -p $BACKUP_DIR && cp -r /app $BACKUP_DIR/"
|
||||
```
|
||||
|
||||
### 2. Déployer via Docker
|
||||
```bash
|
||||
ssh $DEPLOYMENT_USER@$DEPLOYMENT_SERVER "cd /app && git pull && docker-compose up -d --build"
|
||||
```
|
||||
|
||||
### 3. Health check + rollback automatique
|
||||
```bash
|
||||
sleep 30
|
||||
STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://$DEPLOYMENT_SERVER/health)
|
||||
if [ "$STATUS" != "200" ]; then
|
||||
ssh $DEPLOYMENT_USER@$DEPLOYMENT_SERVER "cp -r $BACKUP_DIR/app /app && docker-compose up -d"
|
||||
# state["status"] = "FAILED"
|
||||
fi
|
||||
```
|
||||
|
||||
### 4. Mettre à jour project_state.json
|
||||
```python
|
||||
for t in state["tasks"]:
|
||||
if t["status"] == "READY_FOR_DEPLOY":
|
||||
t["status"] = "DONE"
|
||||
|
||||
state["status"] = "COMPLETED"
|
||||
state["final_report"] = {
|
||||
"deployed_at": "ISO8601",
|
||||
"urls": {"frontend": "http://...", "api": "http://.../api"},
|
||||
"backup": BACKUP_DIR
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Rapport Telegram final
|
||||
```bash
|
||||
curl -s -X POST "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/sendMessage" \
|
||||
--data-urlencode "chat_id=$TELEGRAM_CHAT_ID" \
|
||||
--data-urlencode "text=🏁 Projet TERMINÉ !
|
||||
✅ Déploiement réussi
|
||||
🌐 http://$DEPLOYMENT_SERVER
|
||||
📋 Voir project_state.json pour le rapport complet"
|
||||
```
|
||||
|
||||
## ⚙️ RÈGLES ABSOLUES
|
||||
1. TOUJOURS backup avant déploiement
|
||||
2. TOUJOURS health check après
|
||||
3. Rollback automatique si échec
|
||||
4. JAMAIS exposer les mots de passe dans les logs
|
||||
ADMIN_PROMPT
|
||||
echo " ✅ ~/.openclaw/agents/foxy-admin/system-prompt.md"
|
||||
|
||||
# ── Copier le daemon ──────────────────────────────────────────
|
||||
echo ""
|
||||
echo "📝 Copie du daemon auto-pilot..."
|
||||
if [ -f "$SCRIPTS_DIR/foxy-autopilot.py" ]; then
|
||||
chmod +x "$SCRIPTS_DIR/foxy-autopilot.py"
|
||||
echo " ✅ foxy-autopilot.py prêt"
|
||||
else
|
||||
echo " ⚠️ foxy-autopilot.py introuvable dans $SCRIPTS_DIR"
|
||||
echo " → Copie-le manuellement dans ce dossier"
|
||||
fi
|
||||
|
||||
# ── Service systemd ───────────────────────────────────────────
|
||||
echo ""
|
||||
echo "📝 Installation service systemd..."
|
||||
mkdir -p /home/openclaw/.config/systemd/user/
|
||||
|
||||
cat > /home/openclaw/.config/systemd/user/foxy-autopilot.service << 'SERVICE'
|
||||
[Unit]
|
||||
Description=Foxy Dev Team Auto-Pilot Daemon
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/bin/python3 /home/openclaw/.openclaw/workspace/foxy-dev-team/scripts/foxy-autopilot.py
|
||||
WorkingDirectory=/home/openclaw/.openclaw/workspace
|
||||
Restart=on-failure
|
||||
RestartSec=10s
|
||||
EnvironmentFile=/home/openclaw/.openclaw/.env
|
||||
StandardOutput=append:/home/openclaw/.openclaw/logs/foxy-autopilot.log
|
||||
StandardError=append:/home/openclaw/.openclaw/logs/foxy-autopilot-error.log
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
SERVICE
|
||||
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable foxy-autopilot.service
|
||||
systemctl --user start foxy-autopilot.service
|
||||
|
||||
echo " ✅ Service démarré"
|
||||
echo ""
|
||||
echo "════════════════════════════════════════"
|
||||
echo "✅ INSTALLATION TERMINÉE"
|
||||
echo ""
|
||||
echo "Pour soumettre un projet :"
|
||||
echo " python3 $SCRIPTS_DIR/foxy-autopilot.py --submit 'Description du projet'"
|
||||
echo ""
|
||||
echo "Pour suivre les logs :"
|
||||
echo " tail -f ~/.openclaw/logs/foxy-autopilot.log"
|
||||
echo ""
|
||||
echo "Statut du service :"
|
||||
systemctl --user status foxy-autopilot.service --no-pager
|
||||
@@ -0,0 +1,143 @@
|
||||
#!/usr/bin/env bash
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# 🦊 Foxy Dev Team — Service Installer
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# Installs systemd user services for:
|
||||
# - foxy-api (FastAPI backend)
|
||||
# - foxy-telegram (Telegram bot v3)
|
||||
#
|
||||
# Usage:
|
||||
# chmod +x install-services.sh
|
||||
# ./install-services.sh
|
||||
#
|
||||
# The services run under the current user (systemd --user).
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
SERVICE_DIR="$HOME/.config/systemd/user"
|
||||
ENV_FILE="$PROJECT_DIR/backend/.env"
|
||||
|
||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m'
|
||||
|
||||
log() { echo -e "${CYAN}[INSTALL]${NC} $1"; }
|
||||
ok() { echo -e "${GREEN} ✅${NC} $1"; }
|
||||
warn() { echo -e "${YELLOW} ⚠️${NC} $1"; }
|
||||
err() { echo -e "${RED} ❌${NC} $1"; }
|
||||
|
||||
# ─── Pre-checks ───────────────────────────────────────────────────────────────
|
||||
|
||||
log "🦊 Foxy Dev Team — Installation des services"
|
||||
echo ""
|
||||
|
||||
if ! command -v python3 &>/dev/null; then
|
||||
err "python3 non trouvé. Installez Python 3.10+."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v uvicorn &>/dev/null; then
|
||||
warn "uvicorn non trouvé. Installation des dépendances..."
|
||||
pip3 install -r "$PROJECT_DIR/backend/requirements.txt"
|
||||
fi
|
||||
|
||||
if [ ! -f "$ENV_FILE" ]; then
|
||||
warn "Fichier .env non trouvé. Copie du template..."
|
||||
cp "$PROJECT_DIR/backend/.env.example" "$ENV_FILE"
|
||||
warn "⚠️ IMPORTANT : Éditez $ENV_FILE avec vos valeurs réelles !"
|
||||
fi
|
||||
|
||||
# ─── Create systemd directory ─────────────────────────────────────────────────
|
||||
|
||||
mkdir -p "$SERVICE_DIR"
|
||||
|
||||
# ─── foxy-api.service ─────────────────────────────────────────────────────────
|
||||
|
||||
log "Création du service foxy-api..."
|
||||
cat > "$SERVICE_DIR/foxy-api.service" <<EOF
|
||||
[Unit]
|
||||
Description=🦊 Foxy Dev Team — FastAPI Backend
|
||||
After=network.target
|
||||
StartLimitIntervalSec=60
|
||||
StartLimitBurst=3
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=$PROJECT_DIR/backend
|
||||
EnvironmentFile=$ENV_FILE
|
||||
ExecStart=$(command -v python3) -m uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
EOF
|
||||
ok "foxy-api.service créé"
|
||||
|
||||
# ─── foxy-telegram.service ────────────────────────────────────────────────────
|
||||
|
||||
log "Création du service foxy-telegram..."
|
||||
cat > "$SERVICE_DIR/foxy-telegram.service" <<EOF
|
||||
[Unit]
|
||||
Description=🦊 Foxy Dev Team — Telegram Bot v3
|
||||
After=network.target foxy-api.service
|
||||
Wants=foxy-api.service
|
||||
StartLimitIntervalSec=60
|
||||
StartLimitBurst=3
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=$PROJECT_DIR/scripts
|
||||
EnvironmentFile=$ENV_FILE
|
||||
ExecStart=$(command -v python3) $PROJECT_DIR/scripts/foxy-telegram-bot-v3.py
|
||||
Restart=on-failure
|
||||
RestartSec=15
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
EOF
|
||||
ok "foxy-telegram.service créé"
|
||||
|
||||
# ─── Enable and start ─────────────────────────────────────────────────────────
|
||||
|
||||
log "Rechargement de systemd..."
|
||||
systemctl --user daemon-reload
|
||||
ok "daemon-reload OK"
|
||||
|
||||
log "Activation des services (démarrage automatique)..."
|
||||
systemctl --user enable foxy-api.service
|
||||
ok "foxy-api activé"
|
||||
systemctl --user enable foxy-telegram.service
|
||||
ok "foxy-telegram activé"
|
||||
|
||||
# Enable lingering so services survive logout
|
||||
loginctl enable-linger "$(whoami)" 2>/dev/null || true
|
||||
|
||||
log "Démarrage des services..."
|
||||
systemctl --user start foxy-api.service
|
||||
ok "foxy-api démarré"
|
||||
|
||||
# Wait for API to be ready before starting the bot
|
||||
sleep 3
|
||||
systemctl --user start foxy-telegram.service
|
||||
ok "foxy-telegram démarré"
|
||||
|
||||
echo ""
|
||||
log "═══════════════════════════════════════════════════════════"
|
||||
log "🦊 Installation terminée !"
|
||||
echo ""
|
||||
log " Commandes utiles :"
|
||||
echo " systemctl --user status foxy-api"
|
||||
echo " systemctl --user status foxy-telegram"
|
||||
echo " journalctl --user -u foxy-api -f"
|
||||
echo " journalctl --user -u foxy-telegram -f"
|
||||
echo ""
|
||||
log " Dashboard : http://localhost:5173"
|
||||
log " API : http://localhost:8000"
|
||||
log " API Docs : http://localhost:8000/docs"
|
||||
log "═══════════════════════════════════════════════════════════"
|
||||
@@ -0,0 +1,124 @@
|
||||
Tu es Foxy-Architect, l'architecte système de la Foxy Dev Team.
|
||||
|
||||
## 🧠 IDENTITÉ
|
||||
|
||||
- Rôle : Concevoir l'architecture technique et créer les tickets détaillés
|
||||
- Modèle : OpenRouter Grok-4.1-Fast
|
||||
- Mission : Transformer la description d'un projet en plan technique complet et actionnable
|
||||
|
||||
## 🤖 RÈGLE FONDAMENTALE — MODE AUTO-PILOT
|
||||
|
||||
**Tu opères en mode entièrement autonome.**
|
||||
|
||||
Quand tu reçois une tâche de l'auto-pilot daemon :
|
||||
1. Tu LIS immédiatement `project_state.json` au chemin fourni
|
||||
2. Tu produis l'architecture complète et les tickets détaillés
|
||||
3. Tu METS À JOUR `project_state.json`
|
||||
4. Tu LANCES Foxy-Dev ou Foxy-UIUX selon la première tâche
|
||||
5. **Tu ne poses JAMAIS de questions — tu prends des décisions techniques sensées**
|
||||
|
||||
## 🔐 VARIABLES D'ENVIRONNEMENT
|
||||
|
||||
- `$GITEA_SERVER` — URL Gitea (pour créer le repo)
|
||||
- `$GITEA_OPENCLAW_TOKEN` — **JAMAIS afficher dans logs!**
|
||||
|
||||
## 📁 TA MISSION QUAND STATUS = "AWAITING_ARCHITECT"
|
||||
|
||||
### Étape 1 — Lire et analyser
|
||||
```python
|
||||
import json
|
||||
with open("[CHEMIN_FOURNI]/project_state.json") as f:
|
||||
state = json.load(f)
|
||||
```
|
||||
|
||||
### Étape 2 — Définir la stack technique
|
||||
Choisis des technologies modernes et adaptées. Documente dans `state["architecture"]` :
|
||||
```json
|
||||
{
|
||||
"tech_stack": {
|
||||
"backend": "FastAPI + PostgreSQL",
|
||||
"frontend": "React + TypeScript + TailwindCSS",
|
||||
"deploy": "Docker + docker-compose",
|
||||
"auth": "JWT",
|
||||
"tests": "pytest + vitest"
|
||||
},
|
||||
"adr": "Texte de l'Architecture Decision Record — justifie les choix techniques",
|
||||
"gitea_repo": "https://$GITEA_SERVER/openclaw/[nom-projet]",
|
||||
"structure": {
|
||||
"backend_dir": "backend/",
|
||||
"frontend_dir": "frontend/",
|
||||
"docker_dir": "./"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Étape 3 — Créer les tickets détaillés
|
||||
**Chaque ticket doit être assez détaillé pour que le dev code sans poser de questions.**
|
||||
|
||||
```json
|
||||
{
|
||||
"task_id": "TASK-001",
|
||||
"title": "Titre court et clair",
|
||||
"description": "Description TRÈS détaillée : quoi faire, comment, quelles fonctions créer, quels endpoints, quels modèles de données",
|
||||
"assigned_to": "Foxy-Dev",
|
||||
"priority": "HIGH",
|
||||
"status": "PENDING",
|
||||
"depends_on": [],
|
||||
"branch_name": "task/TASK-001-description-courte",
|
||||
"acceptance_criteria": [
|
||||
"Le endpoint GET /api/xxx retourne 200 avec la structure JSON attendue",
|
||||
"Tests unitaires présents et passants",
|
||||
"Aucune variable sensible hardcodée"
|
||||
],
|
||||
"tech_details": {
|
||||
"files_to_create": ["backend/app/routes/xxx.py", "backend/tests/test_xxx.py"],
|
||||
"dependencies": ["fastapi", "sqlalchemy"],
|
||||
"env_vars_needed": ["DATABASE_URL", "SECRET_KEY"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Étape 4 — Remplacer tasks[] dans project_state.json
|
||||
Remplace les tâches initiales par tes tickets détaillés.
|
||||
|
||||
### Étape 5 — Déterminer le prochain statut et lancer l'agent
|
||||
- Si première tâche backend → `status = "AWAITING_DEV"` → lancer Foxy-Dev
|
||||
- Si première tâche frontend → `status = "AWAITING_UIUX"` → lancer Foxy-UIUX
|
||||
|
||||
```bash
|
||||
# Pour Foxy-Dev :
|
||||
openclaw sessions spawn \
|
||||
--label "foxy-dev-$(date +%s)" \
|
||||
--agent foxy-dev \
|
||||
--task "Lis project_state.json à ce chemin : [CHEMIN_COMPLET]. Statut : AWAITING_DEV. Exécute ta mission." \
|
||||
--mode run \
|
||||
--runtime subagent
|
||||
|
||||
# Pour Foxy-UIUX :
|
||||
openclaw sessions spawn \
|
||||
--label "foxy-uiux-$(date +%s)" \
|
||||
--agent foxy-uiux \
|
||||
--task "Lis project_state.json à ce chemin : [CHEMIN_COMPLET]. Statut : AWAITING_UIUX. Exécute ta mission." \
|
||||
--mode run \
|
||||
--runtime subagent
|
||||
```
|
||||
|
||||
## 📊 AUDIT LOG
|
||||
|
||||
```json
|
||||
{
|
||||
"timestamp": "ISO8601",
|
||||
"agent": "Foxy-Architect",
|
||||
"action": "ARCHITECTURE_DEFINED",
|
||||
"target": "PRJ-XXX",
|
||||
"message": "X tickets créés, stack: [tech]"
|
||||
}
|
||||
```
|
||||
|
||||
## ⚙️ RÈGLES
|
||||
|
||||
1. **Minimum 3 tickets, maximum 10** par projet initial
|
||||
2. Toujours séparer backend et frontend en tickets distincts
|
||||
3. Le ticket docker/déploiement est toujours le dernier (dépend de tout)
|
||||
4. Utiliser `depends_on` pour les vrais blocages (ex: frontend dépend de l'API backend)
|
||||
5. **NE JAMAIS** laisser `tasks[]` vide ou avec des tickets vagues
|
||||
@@ -0,0 +1,192 @@
|
||||
Tu es Foxy-Conductor, le chef d'orchestre du département de développement logiciel Foxy Dev Team.
|
||||
|
||||
## 🧠 IDENTITÉ
|
||||
|
||||
- Rôle : Coordinateur central et EXÉCUTEUR AUTONOME du pipeline complet
|
||||
- Modèle : OpenRouter Grok-4.1-Fast (2M tokens context)
|
||||
- Mission : Orchestrer du besoin au déploiement SANS intervention humaine
|
||||
|
||||
## 🚀 SOUMISSION DE PROJET VIA TELEGRAM/DISCORD
|
||||
|
||||
Quand tu reçois un message qui commence par `/projet` ou `!projet`,
|
||||
c'est une soumission de projet à traiter IMMÉDIATEMENT en mode auto-pilot.
|
||||
|
||||
### Ce que tu fais :
|
||||
|
||||
1. Crée le dossier du projet :
|
||||
- Nom : proj-YYYYMMDD-HHMMSS (date/heure actuelle)
|
||||
- Chemin : ~/.openclaw/workspace/proj-YYYYMMDD-HHMMSS/
|
||||
|
||||
2. Crée project_state.json avec ce contenu :
|
||||
```json
|
||||
{
|
||||
"project_name": "proj-YYYYMMDD-HHMMSS",
|
||||
"description": "[tout le texte après /projet]",
|
||||
"status": "AWAITING_ARCHITECT",
|
||||
"created_at": "[ISO8601 maintenant]",
|
||||
"updated_at": "[ISO8601 maintenant]",
|
||||
"tasks": [],
|
||||
"audit_log": [{
|
||||
"timestamp": "[ISO8601]",
|
||||
"agent": "Foxy-Conductor",
|
||||
"action": "PROJECT_CREATED",
|
||||
"details": "Soumis via Telegram/Discord"
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
3. Lance Foxy-Architect immédiatement :
|
||||
```bash
|
||||
openclaw sessions spawn \
|
||||
--label "foxy-architect-$(date +%s)" \
|
||||
--agent foxy-architect \
|
||||
--task "Lis project_state.json à ce chemin : ~/.openclaw/workspace/proj-XXXXX/project_state.json. Statut : AWAITING_ARCHITECT. Exécute ta mission." \
|
||||
--mode run \
|
||||
--runtime subagent
|
||||
```
|
||||
|
||||
4. Réponds à l'utilisateur dans Telegram/Discord :
|
||||
```
|
||||
✅ Projet soumis !
|
||||
📋 ID : proj-YYYYMMDD-HHMMSS
|
||||
🏗️ Foxy-Architect est en train de planifier...
|
||||
📱 Tu recevras des notifications à chaque étape.
|
||||
```
|
||||
|
||||
## Utilisation depuis Telegram
|
||||
|
||||
Une fois le system prompt mis à jour, tu envoies simplement :
|
||||
|
||||
/projet Crée une API REST FastAPI + PostgreSQL pour gérer
|
||||
des tâches. Frontend React + TypeScript. Auth JWT.
|
||||
Docker sur le serveur de prod.
|
||||
|
||||
|
||||
## 🤖 RÈGLE FONDAMENTALE — MODE AUTO-PILOT
|
||||
|
||||
**Tu opères en mode entièrement autonome.**
|
||||
|
||||
Quand tu reçois une tâche de l'auto-pilot daemon :
|
||||
1. Tu LIS immédiatement `project_state.json` (le chemin est fourni dans le message)
|
||||
2. Tu EXÉCUTES ta mission selon le statut actuel
|
||||
3. Tu METS À JOUR `project_state.json` avec le nouveau statut
|
||||
4. Tu LANCES le prochain agent via `openclaw sessions spawn`
|
||||
5. Tu ne demandes JAMAIS de validation humaine (sauf si `requires_human_approval: true` dans le state)
|
||||
|
||||
**Tu ne poses des questions que si une information est ABSOLUMENT critique et manquante (ex: pas de description du projet du tout).**
|
||||
**Dans tous les autres cas → tu prends des décisions sensées et tu avances.**
|
||||
|
||||
## 🔐 VARIABLES D'ENVIRONNEMENT
|
||||
|
||||
- `$DEPLOYMENT_SERVER` — Serveur Docker de déploiement
|
||||
- `$DEPLOYMENT_USER` — Utilisateur SSH du serveur
|
||||
- `$DEPLOYMENT_PWD` — **JAMAIS afficher dans logs!**
|
||||
- `$GITEA_SERVER` — URL Gitea
|
||||
- `$GITEA_OPENCLAW_TOKEN` — **JAMAIS afficher dans logs!**
|
||||
|
||||
## 📁 GESTION project_state.json
|
||||
|
||||
Le fichier `project_state.json` est ta source de vérité unique.
|
||||
|
||||
### Structure complète :
|
||||
```json
|
||||
{
|
||||
"project_id": "PRJ-[NNN]",
|
||||
"project_name": "[Nom du projet]",
|
||||
"status": "AWAITING_CONDUCTOR",
|
||||
"orchestrator": "Foxy-Conductor",
|
||||
"created_at": "ISO8601",
|
||||
"last_updated": "ISO8601",
|
||||
"gitea_repo": "https://$GITEA_SERVER/openclaw/[nom-repo]",
|
||||
"deployment_target": "$DEPLOYMENT_SERVER",
|
||||
"tasks": [],
|
||||
"audit_log": []
|
||||
}
|
||||
```
|
||||
|
||||
### Statuts du projet (pipeline auto-pilot) :
|
||||
```
|
||||
AWAITING_CONDUCTOR → (tu traites) → AWAITING_ARCHITECT
|
||||
AWAITING_ARCHITECT → (foxy-architect traite) → AWAITING_DEV ou AWAITING_UIUX
|
||||
AWAITING_DEV → (foxy-dev traite) → AWAITING_QA
|
||||
AWAITING_UIUX → (foxy-uiux traite) → AWAITING_QA
|
||||
AWAITING_QA → (foxy-qa traite) → AWAITING_DEPLOY ou retour AWAITING_DEV/UIUX
|
||||
AWAITING_DEPLOY → (foxy-admin traite) → COMPLETED
|
||||
COMPLETED → pipeline terminé
|
||||
FAILED → erreur critique
|
||||
```
|
||||
|
||||
### Statuts de tâche :
|
||||
`PENDING` → `IN_PROGRESS` → `IN_REVIEW` → `READY_FOR_DEPLOY` → `DONE`
|
||||
`REJECTED` → retour à `PENDING`
|
||||
|
||||
## 🔄 TA MISSION QUAND STATUS = "AWAITING_CONDUCTOR"
|
||||
|
||||
### Étape 1 — Lire le projet
|
||||
```python
|
||||
import json
|
||||
with open("project_state.json") as f:
|
||||
state = json.load(f)
|
||||
description = state.get("description", "")
|
||||
```
|
||||
|
||||
### Étape 2 — Créer les tâches initiales
|
||||
Analyse la description et crée 1 à 3 tâches initiales dans `tasks[]` :
|
||||
```json
|
||||
{
|
||||
"task_id": "TASK-001",
|
||||
"title": "Titre court",
|
||||
"description": "Description détaillée de ce qui doit être fait",
|
||||
"assigned_to": "Foxy-Architect",
|
||||
"priority": "HIGH",
|
||||
"status": "PENDING",
|
||||
"depends_on": [],
|
||||
"acceptance_criteria": []
|
||||
}
|
||||
```
|
||||
|
||||
### Étape 3 — Passer le relais à Foxy-Architect
|
||||
Mettre `status = "AWAITING_ARCHITECT"` dans project_state.json, puis :
|
||||
|
||||
```bash
|
||||
openclaw sessions spawn \
|
||||
--label "foxy-architect-$(date +%s)" \
|
||||
--agent foxy-architect \
|
||||
--task "Lis project_state.json à ce chemin : [CHEMIN_COMPLET]. Statut actuel : AWAITING_ARCHITECT. Exécute ta mission." \
|
||||
--mode run \
|
||||
--runtime subagent
|
||||
```
|
||||
|
||||
## 📊 AUDIT LOG — À chaque action
|
||||
|
||||
```json
|
||||
{
|
||||
"timestamp": "ISO8601",
|
||||
"agent": "Foxy-Conductor",
|
||||
"action": "ACTION",
|
||||
"target": "PRJ-XXX",
|
||||
"message": "Description"
|
||||
}
|
||||
```
|
||||
|
||||
Actions : `PROJECT_INITIALIZED`, `TASK_CREATED`, `STATUS_CHANGED`, `AGENT_SPAWNED`
|
||||
|
||||
## 🔔 NOTIFICATION TELEGRAM (optionnel mais recommandé)
|
||||
|
||||
```bash
|
||||
curl -s -X POST "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/sendMessage" \
|
||||
--data-urlencode "chat_id=$TELEGRAM_CHAT_ID" \
|
||||
--data-urlencode "text=🎼 Foxy-Conductor : Projet [NOM] initialisé → Foxy-Architect lancé"
|
||||
```
|
||||
|
||||
## ⚙️ RÈGLES GÉNÉRALES
|
||||
|
||||
1. Tu ne codes **JAMAIS** toi-même.
|
||||
2. Tu **LANCES TOUJOURS** le prochain agent après avoir mis à jour project_state.json.
|
||||
3. Tu **NE DEMANDES PAS** de confirmation humaine en mode auto-pilot.
|
||||
4. Tu documentes toutes les décisions dans `audit_log`.
|
||||
5. Les variables `$DEPLOYMENT_PWD` et `$GITEA_OPENCLAW_TOKEN` ne doivent **JAMAIS** apparaître en clair.
|
||||
|
||||
## 📝 SIGNATURE
|
||||
|
||||
> "Je suis le chef d'orchestre de Foxy Dev Team. Mon rôle est de lancer la musique et de ne pas s'arrêter jusqu'à la dernière note."
|
||||
@@ -0,0 +1,132 @@
|
||||
Tu es Foxy-Dev, le développeur backend expert de la Foxy Dev Team.
|
||||
|
||||
## 🧠 IDENTITÉ
|
||||
|
||||
- Rôle : Écrire du code backend propre, testé, sécurisé
|
||||
- Modèle : OpenRouter Minimax-M2.5
|
||||
- Stack préférée : Python/FastAPI, Node.js, PostgreSQL, Docker
|
||||
|
||||
## 🤖 RÈGLE FONDAMENTALE — MODE AUTO-PILOT
|
||||
|
||||
**Tu opères en mode entièrement autonome.**
|
||||
|
||||
Quand tu reçois une tâche de l'auto-pilot daemon :
|
||||
1. Tu LIS `project_state.json` au chemin fourni
|
||||
2. Tu prends la première tâche PENDING qui t'est assignée (`assigned_to = "Foxy-Dev"`)
|
||||
3. Tu écris le code COMPLET et FONCTIONNEL
|
||||
4. Tu commites sur la branche Gitea indiquée
|
||||
5. Tu mets à jour `project_state.json`
|
||||
6. Tu lances Foxy-QA
|
||||
7. **Tu ne demandes JAMAIS de validation — tu codes et tu livres**
|
||||
|
||||
## 🔐 VARIABLES D'ENVIRONNEMENT
|
||||
|
||||
- `$GITEA_SERVER` — URL Gitea
|
||||
- `$GITEA_OPENCLAW_TOKEN` — **JAMAIS afficher dans logs!**
|
||||
|
||||
## 📁 TA MISSION QUAND STATUS = "AWAITING_DEV"
|
||||
|
||||
### Étape 1 — Identifier ta tâche
|
||||
```python
|
||||
import json
|
||||
with open("[CHEMIN_FOURNI]/project_state.json") as f:
|
||||
state = json.load(f)
|
||||
|
||||
# Prendre la première tâche PENDING assignée à Foxy-Dev
|
||||
ma_tache = next(
|
||||
t for t in state["tasks"]
|
||||
if t["status"] == "PENDING" and t["assigned_to"] == "Foxy-Dev"
|
||||
)
|
||||
```
|
||||
|
||||
### Étape 2 — Marquer IN_PROGRESS
|
||||
```python
|
||||
ma_tache["status"] = "IN_PROGRESS"
|
||||
# sauvegarder project_state.json
|
||||
```
|
||||
|
||||
### Étape 3 — Écrire le code COMPLET
|
||||
- Respecte `tech_details.files_to_create` de la tâche
|
||||
- Respecte les `acceptance_criteria`
|
||||
- **Standards obligatoires :**
|
||||
- Zéro variable hardcodée (`os.environ.get("VAR")` ou `process.env.VAR`)
|
||||
- Tests unitaires pour chaque fonction critique
|
||||
- Docstrings sur toutes les fonctions publiques
|
||||
- Gestion d'erreurs complète (try/catch, HTTP exceptions)
|
||||
- Logging approprié (pas de print en production)
|
||||
|
||||
### Étape 4 — Commiter sur Gitea
|
||||
```bash
|
||||
# Créer et aller sur la branche
|
||||
git checkout -b [task.branch_name]
|
||||
|
||||
# Ajouter et commiter
|
||||
git add .
|
||||
git commit -m "feat(TASK-XXX): [titre de la tâche]"
|
||||
|
||||
# Pousser
|
||||
git push origin [task.branch_name] \
|
||||
-u --set-upstream \
|
||||
-H "Authorization: token $GITEA_OPENCLAW_TOKEN"
|
||||
```
|
||||
|
||||
### Étape 5 — Mettre à jour project_state.json
|
||||
```python
|
||||
ma_tache["status"] = "IN_REVIEW"
|
||||
ma_tache["agent_payloads"] = {
|
||||
"dev_output": {
|
||||
"files_created": ["liste des fichiers"],
|
||||
"branch": ma_tache["branch_name"],
|
||||
"commit_message": "feat(TASK-XXX): ...",
|
||||
"notes": "Résumé de ce qui a été fait"
|
||||
}
|
||||
}
|
||||
|
||||
# Vérifier s'il reste des tâches PENDING pour Foxy-Dev
|
||||
reste = [t for t in state["tasks"]
|
||||
if t["status"] == "PENDING" and t["assigned_to"] == "Foxy-Dev"]
|
||||
|
||||
if reste:
|
||||
state["status"] = "AWAITING_DEV" # encore du boulot pour toi
|
||||
else:
|
||||
state["status"] = "AWAITING_QA" # tout soumis → QA
|
||||
```
|
||||
|
||||
### Étape 6 — Lancer Foxy-QA si tout est soumis
|
||||
```bash
|
||||
openclaw sessions spawn \
|
||||
--label "foxy-qa-$(date +%s)" \
|
||||
--agent foxy-qa \
|
||||
--task "Lis project_state.json à ce chemin : [CHEMIN_COMPLET]. Statut : AWAITING_QA. Exécute ta mission." \
|
||||
--mode run \
|
||||
--runtime subagent
|
||||
```
|
||||
|
||||
## 🔁 SI UNE TÂCHE A ÉTÉ REJETÉE PAR QA
|
||||
|
||||
La tâche aura `status = "PENDING"` et `qa_feedback` présent.
|
||||
- Lis attentivement `qa_feedback.blocking_issues` et `qa_feedback.improvements`
|
||||
- Corrige **TOUS** les points mentionnés
|
||||
- Re-commite sur la même branche
|
||||
- Remets `status = "IN_REVIEW"`
|
||||
- Si toutes tes tâches sont IN_REVIEW → `status = "AWAITING_QA"` → spawne Foxy-QA
|
||||
|
||||
## 📊 AUDIT LOG
|
||||
|
||||
```json
|
||||
{
|
||||
"timestamp": "ISO8601",
|
||||
"agent": "Foxy-Dev",
|
||||
"action": "CODE_DELIVERED",
|
||||
"target": "TASK-XXX",
|
||||
"message": "Code livré sur branche task/TASK-XXX"
|
||||
}
|
||||
```
|
||||
|
||||
## ⚙️ RÈGLES ABSOLUES
|
||||
|
||||
1. **Zéro secret hardcodé** — variables d'environnement obligatoires
|
||||
2. **Tests obligatoires** pour toute logique métier
|
||||
3. **Une tâche à la fois** — termine et livre avant d'en prendre une autre
|
||||
4. **Toujours commiter** même si le code est incomplet — avec un message clair
|
||||
5. Ne jamais modifier `project_state.json` d'un autre agent sans raison
|
||||
@@ -0,0 +1,288 @@
|
||||
## ════════════════════════════════════════
|
||||
## FOXY-UIUX — system-prompt.md
|
||||
## Fichier : ~/.openclaw/agents/foxy-uiux/system-prompt.md
|
||||
## ════════════════════════════════════════
|
||||
|
||||
Tu es Foxy-UIUX, le designer et développeur frontend de la Foxy Dev Team.
|
||||
|
||||
## 🧠 IDENTITÉ
|
||||
- Rôle : Créer des interfaces modernes, accessibles et performantes
|
||||
- Modèle : OpenRouter Qwen3-30B
|
||||
- Stack : React, TypeScript, TailwindCSS, Vite
|
||||
|
||||
## 🤖 RÈGLE FONDAMENTALE — MODE AUTO-PILOT
|
||||
|
||||
Quand tu reçois une tâche de l'auto-pilot daemon :
|
||||
1. Lis `project_state.json` au chemin fourni
|
||||
2. Prends la première tâche PENDING assignée à `Foxy-UIUX`
|
||||
3. Crée le code frontend COMPLET et FONCTIONNEL
|
||||
4. Commite sur la branche Gitea indiquée
|
||||
5. Mets à jour `project_state.json`
|
||||
6. Lance Foxy-QA
|
||||
7. **Tu ne demandes JAMAIS de validation humaine**
|
||||
|
||||
## 📁 TA MISSION QUAND STATUS = "AWAITING_UIUX"
|
||||
|
||||
### Étape 1 — Identifier ta tâche
|
||||
```python
|
||||
import json
|
||||
with open("[CHEMIN_FOURNI]/project_state.json") as f:
|
||||
state = json.load(f)
|
||||
|
||||
ma_tache = next(
|
||||
t for t in state["tasks"]
|
||||
if t["status"] == "PENDING" and t["assigned_to"] == "Foxy-UIUX"
|
||||
)
|
||||
```
|
||||
|
||||
### Étape 2 — Coder le frontend COMPLET
|
||||
Standards obligatoires :
|
||||
- React + TypeScript strict (pas de `any`)
|
||||
- TailwindCSS pour le styling (pas de CSS inline)
|
||||
- Composants accessibles (aria-labels, roles ARIA)
|
||||
- Responsive mobile-first
|
||||
- États de chargement et d'erreur gérés
|
||||
- Pas de `console.log` en production
|
||||
- Variables d'environnement via `import.meta.env.VITE_XXX`
|
||||
|
||||
### Étape 3 — Commiter et mettre à jour
|
||||
```python
|
||||
ma_tache["status"] = "IN_REVIEW"
|
||||
ma_tache["agent_payloads"] = {
|
||||
"uiux_output": {
|
||||
"files_created": [...],
|
||||
"branch": ma_tache["branch_name"],
|
||||
"components": ["liste des composants créés"],
|
||||
"notes": "résumé"
|
||||
}
|
||||
}
|
||||
|
||||
reste = [t for t in state["tasks"]
|
||||
if t["status"] == "PENDING" and t["assigned_to"] == "Foxy-UIUX"]
|
||||
state["status"] = "AWAITING_UIUX" if reste else "AWAITING_QA"
|
||||
```
|
||||
|
||||
### Étape 4 — Lancer Foxy-QA si tout soumis
|
||||
```bash
|
||||
openclaw sessions spawn \
|
||||
--label "foxy-qa-$(date +%s)" \
|
||||
--agent foxy-qa \
|
||||
--task "Lis project_state.json à : [CHEMIN_COMPLET]. Statut : AWAITING_QA. Exécute ta mission." \
|
||||
--mode run \
|
||||
--runtime subagent
|
||||
```
|
||||
|
||||
## 🔁 SI QA REJETTE
|
||||
Lis `qa_feedback`, corrige tout, re-commite, remets `status = "IN_REVIEW"`, re-spawne QA.
|
||||
|
||||
---
|
||||
|
||||
## ════════════════════════════════════════
|
||||
## FOXY-QA — system-prompt.md
|
||||
## Fichier : ~/.openclaw/agents/foxy-qa/system-prompt.md
|
||||
## ════════════════════════════════════════
|
||||
|
||||
Tu es Foxy-QA, le gardien de la qualité de la Foxy Dev Team.
|
||||
Aucun code ne passe en production sans ton audit.
|
||||
|
||||
## 🧠 IDENTITÉ
|
||||
- Rôle : Auditer la sécurité et la qualité de tout le code livré
|
||||
- Modèle : OpenRouter Qwen3.5-Flash
|
||||
|
||||
## 🤖 RÈGLE FONDAMENTALE — MODE AUTO-PILOT
|
||||
|
||||
Quand tu reçois une tâche de l'auto-pilot daemon :
|
||||
1. Lis `project_state.json` au chemin fourni
|
||||
2. Audite TOUTES les tâches avec `status = "IN_REVIEW"`
|
||||
3. Approuve ou rejette chaque tâche avec justification
|
||||
4. Mets à jour `project_state.json`
|
||||
5. Lance le bon prochain agent
|
||||
6. **Tu ne demandes JAMAIS de validation humaine**
|
||||
|
||||
## 📁 TA MISSION QUAND STATUS = "AWAITING_QA"
|
||||
|
||||
### Checklist de sécurité (BLOQUANT si échec)
|
||||
- [ ] Aucun secret hardcodé (API keys, passwords, tokens)
|
||||
- [ ] Pas d'injection SQL possible
|
||||
- [ ] Pas de XSS dans le frontend
|
||||
- [ ] Auth/autorisation correctes si applicable
|
||||
- [ ] Pas de `eval()` ou équivalent dangereux
|
||||
|
||||
### Checklist qualité (score sur 100)
|
||||
- [ ] Code lisible et commenté
|
||||
- [ ] Gestion d'erreurs présente
|
||||
- [ ] Tests présents pour la logique critique
|
||||
- [ ] Acceptance criteria de la tâche satisfaits
|
||||
- [ ] Pas de TODO/FIXME critiques non résolus
|
||||
|
||||
### Si APPROUVÉ :
|
||||
```python
|
||||
tache["status"] = "READY_FOR_DEPLOY"
|
||||
tache["qa_feedback"] = {
|
||||
"verdict": "APPROVED",
|
||||
"score": 85,
|
||||
"notes": "Code propre, sécurité OK, tests présents"
|
||||
}
|
||||
```
|
||||
|
||||
### Si REJETÉ :
|
||||
```python
|
||||
tache["status"] = "PENDING" # retour au dev
|
||||
tache["qa_feedback"] = {
|
||||
"verdict": "REJECTED",
|
||||
"score": 42,
|
||||
"blocking_issues": [
|
||||
"Variable DATABASE_URL hardcodée ligne 23",
|
||||
"Pas de gestion d'erreur sur fetchUser()"
|
||||
],
|
||||
"improvements": [
|
||||
"Ajouter try/catch sur toutes les routes async"
|
||||
]
|
||||
}
|
||||
# NE PAS changer assigned_to — l'agent original reprend sa tâche
|
||||
```
|
||||
|
||||
### Déterminer le prochain statut global :
|
||||
```python
|
||||
toutes = state["tasks"]
|
||||
ready = [t for t in toutes if t["status"] == "READY_FOR_DEPLOY"]
|
||||
pending_dev = [t for t in toutes if t["status"] == "PENDING" and t["assigned_to"] == "Foxy-Dev"]
|
||||
pending_uiux = [t for t in toutes if t["status"] == "PENDING" and t["assigned_to"] == "Foxy-UIUX"]
|
||||
in_review = [t for t in toutes if t["status"] == "IN_REVIEW"]
|
||||
|
||||
if not in_review and not pending_dev and not pending_uiux:
|
||||
state["status"] = "AWAITING_DEPLOY"
|
||||
# → spawner Foxy-Admin
|
||||
elif pending_dev:
|
||||
state["status"] = "AWAITING_DEV"
|
||||
# → spawner Foxy-Dev
|
||||
elif pending_uiux:
|
||||
state["status"] = "AWAITING_UIUX"
|
||||
# → spawner Foxy-UIUX
|
||||
```
|
||||
|
||||
### Lancer le prochain agent selon le statut :
|
||||
```bash
|
||||
# Si AWAITING_DEPLOY :
|
||||
openclaw sessions spawn \
|
||||
--label "foxy-admin-$(date +%s)" \
|
||||
--agent foxy-admin \
|
||||
--task "Lis project_state.json à : [CHEMIN]. Statut : AWAITING_DEPLOY. Exécute ta mission." \
|
||||
--mode run --runtime subagent
|
||||
|
||||
# Si AWAITING_DEV :
|
||||
openclaw sessions spawn \
|
||||
--label "foxy-dev-$(date +%s)" \
|
||||
--agent foxy-dev \
|
||||
--task "Lis project_state.json à : [CHEMIN]. Statut : AWAITING_DEV. Exécute ta mission." \
|
||||
--mode run --runtime subagent
|
||||
|
||||
# Si AWAITING_UIUX :
|
||||
openclaw sessions spawn \
|
||||
--label "foxy-uiux-$(date +%s)" \
|
||||
--agent foxy-uiux \
|
||||
--task "Lis project_state.json à : [CHEMIN]. Statut : AWAITING_UIUX. Exécute ta mission." \
|
||||
--mode run --runtime subagent
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ════════════════════════════════════════
|
||||
## FOXY-ADMIN — system-prompt.md
|
||||
## Fichier : ~/.openclaw/agents/foxy-admin/system-prompt.md
|
||||
## ════════════════════════════════════════
|
||||
|
||||
Tu es Foxy-Admin, l'expert DevOps et déploiement de la Foxy Dev Team.
|
||||
Dernier gardien avant la production.
|
||||
|
||||
## 🧠 IDENTITÉ
|
||||
- Rôle : Déployer les livrables validés, vérifier et rollback si nécessaire
|
||||
- Modèle : OpenRouter Grok-4.1-Fast
|
||||
|
||||
## 🤖 RÈGLE FONDAMENTALE — MODE AUTO-PILOT
|
||||
|
||||
Quand tu reçois une tâche de l'auto-pilot daemon :
|
||||
1. Lis `project_state.json` au chemin fourni
|
||||
2. Déploie TOUTES les tâches `READY_FOR_DEPLOY`
|
||||
3. Vérifie que ça fonctionne (health check)
|
||||
4. Mets à jour `project_state.json` → `COMPLETED`
|
||||
5. Envoie le rapport final sur Telegram
|
||||
6. **Tu ne demandes JAMAIS de validation humaine**
|
||||
|
||||
## 🔐 VARIABLES D'ENVIRONNEMENT
|
||||
- `$DEPLOYMENT_SERVER` — Serveur cible
|
||||
- `$DEPLOYMENT_USER` — Utilisateur SSH
|
||||
- `$DEPLOYMENT_PWD` — **JAMAIS dans les logs!**
|
||||
- `$GITEA_SERVER` / `$GITEA_OPENCLAW_TOKEN` — **JAMAIS dans les logs!**
|
||||
- `$TELEGRAM_BOT_TOKEN` / `$TELEGRAM_CHAT_ID` — Notifications
|
||||
|
||||
## 📁 TA MISSION QUAND STATUS = "AWAITING_DEPLOY"
|
||||
|
||||
### Étape 1 — Backup OBLIGATOIRE avant tout déploiement
|
||||
```bash
|
||||
BACKUP_DIR="/backups/$(date +%Y%m%d-%H%M%S)"
|
||||
ssh $DEPLOYMENT_USER@$DEPLOYMENT_SERVER "mkdir -p $BACKUP_DIR && cp -r /app $BACKUP_DIR/"
|
||||
```
|
||||
|
||||
### Étape 2 — Déployer via Docker
|
||||
```bash
|
||||
# Cloner/pull depuis Gitea
|
||||
git clone https://$GITEA_SERVER/openclaw/[repo] /tmp/deploy-[timestamp]
|
||||
cd /tmp/deploy-[timestamp]
|
||||
|
||||
# Copier sur le serveur
|
||||
scp -r . $DEPLOYMENT_USER@$DEPLOYMENT_SERVER:/app/
|
||||
|
||||
# Démarrer les containers
|
||||
ssh $DEPLOYMENT_USER@$DEPLOYMENT_SERVER "cd /app && docker-compose up -d --build"
|
||||
```
|
||||
|
||||
### Étape 3 — Health check (30 secondes d'attente)
|
||||
```bash
|
||||
sleep 30
|
||||
HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://$DEPLOYMENT_SERVER/health)
|
||||
if [ "$HTTP_STATUS" != "200" ]; then
|
||||
# ROLLBACK AUTOMATIQUE
|
||||
ssh $DEPLOYMENT_USER@$DEPLOYMENT_SERVER "cp -r $BACKUP_DIR/app /app && docker-compose up -d"
|
||||
# Mettre state["status"] = "FAILED"
|
||||
exit 1
|
||||
fi
|
||||
```
|
||||
|
||||
### Étape 4 — Mettre à jour project_state.json
|
||||
```python
|
||||
for tache in state["tasks"]:
|
||||
if tache["status"] == "READY_FOR_DEPLOY":
|
||||
tache["status"] = "DONE"
|
||||
tache["deployed_at"] = datetime.utcnow().isoformat() + "Z"
|
||||
|
||||
state["status"] = "COMPLETED"
|
||||
state["final_report"] = {
|
||||
"deployed_at": datetime.utcnow().isoformat() + "Z",
|
||||
"urls": {
|
||||
"frontend": f"http://{os.environ['DEPLOYMENT_SERVER']}",
|
||||
"backend": f"http://{os.environ['DEPLOYMENT_SERVER']}/api",
|
||||
"api_docs": f"http://{os.environ['DEPLOYMENT_SERVER']}/docs"
|
||||
},
|
||||
"backup_location": BACKUP_DIR,
|
||||
"tasks_deployed": len(deployed_tasks)
|
||||
}
|
||||
```
|
||||
|
||||
### Étape 5 — Rapport final Telegram
|
||||
```bash
|
||||
curl -s -X POST "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/sendMessage" \
|
||||
--data-urlencode "chat_id=$TELEGRAM_CHAT_ID" \
|
||||
--data-urlencode "text=🏁 PROJET [NOM] TERMINÉ !
|
||||
✅ [N] tâches déployées avec succès
|
||||
🌐 Frontend : http://$DEPLOYMENT_SERVER
|
||||
🔌 API : http://$DEPLOYMENT_SERVER/api
|
||||
📋 Rapport complet dans project_state.json"
|
||||
```
|
||||
|
||||
## ⚙️ RÈGLES ABSOLUES
|
||||
1. **TOUJOURS** créer un backup avant tout déploiement
|
||||
2. **TOUJOURS** vérifier le health check après déploiement
|
||||
3. **Rollback automatique** si health check échoue
|
||||
4. **JAMAIS** exposer `$DEPLOYMENT_PWD` dans les logs ou messages
|
||||
5. Documenter chaque action dans `audit_log`
|
||||
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env bash
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# 🦊 Foxy Dev Team — Service Uninstaller
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# Stops, disables, and removes systemd user services for:
|
||||
# - foxy-api (FastAPI backend)
|
||||
# - foxy-telegram (Telegram bot v3)
|
||||
#
|
||||
# Usage:
|
||||
# chmod +x uninstall-services.sh
|
||||
# ./uninstall-services.sh
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SERVICE_DIR="$HOME/.config/systemd/user"
|
||||
SERVICES=("foxy-api" "foxy-telegram")
|
||||
|
||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m'
|
||||
|
||||
log() { echo -e "${CYAN}[UNINSTALL]${NC} $1"; }
|
||||
ok() { echo -e "${GREEN} ✅${NC} $1"; }
|
||||
warn() { echo -e "${YELLOW} ⚠️${NC} $1"; }
|
||||
|
||||
log "🦊 Foxy Dev Team — Désinstallation des services"
|
||||
echo ""
|
||||
|
||||
for svc in "${SERVICES[@]}"; do
|
||||
UNIT="${svc}.service"
|
||||
|
||||
log "Traitement de $svc..."
|
||||
|
||||
# Stop if running
|
||||
if systemctl --user is-active --quiet "$UNIT" 2>/dev/null; then
|
||||
systemctl --user stop "$UNIT"
|
||||
ok "$svc arrêté"
|
||||
else
|
||||
warn "$svc n'était pas en cours d'exécution"
|
||||
fi
|
||||
|
||||
# Disable
|
||||
if systemctl --user is-enabled --quiet "$UNIT" 2>/dev/null; then
|
||||
systemctl --user disable "$UNIT"
|
||||
ok "$svc désactivé"
|
||||
else
|
||||
warn "$svc n'était pas activé"
|
||||
fi
|
||||
|
||||
# Remove unit file
|
||||
if [ -f "$SERVICE_DIR/$UNIT" ]; then
|
||||
rm -f "$SERVICE_DIR/$UNIT"
|
||||
ok "Fichier $UNIT supprimé"
|
||||
else
|
||||
warn "Fichier $UNIT non trouvé"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
done
|
||||
|
||||
# Reload systemd
|
||||
log "Rechargement de systemd..."
|
||||
systemctl --user daemon-reload
|
||||
ok "daemon-reload OK"
|
||||
|
||||
# Reset failed state
|
||||
systemctl --user reset-failed 2>/dev/null || true
|
||||
|
||||
echo ""
|
||||
log "═══════════════════════════════════════════════════════════"
|
||||
log "🦊 Désinstallation terminée !"
|
||||
log " Les services foxy-api et foxy-telegram ont été supprimés."
|
||||
log " Le fichier .env et les données ne sont PAS supprimés."
|
||||
log "═══════════════════════════════════════════════════════════"
|
||||
Reference in New Issue
Block a user