fix(hermes-auto-improve): trigger asynchrone — répond en <10ms, script en background

Le run Hermes prend ~3min (5 appels LLM). Le serveur retourne maintenant
{"started": true} immédiatement. n8n attend 5min via Wait node puis GET /status.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
alkatrazz 2026-06-03 00:35:06 +02:00
parent 5d76ff1f4b
commit a3bfc956e7

View file

@ -3,12 +3,16 @@
Serveur HTTP minimal pour déclencher hermes-auto-improve depuis n8n. Serveur HTTP minimal pour déclencher hermes-auto-improve depuis n8n.
Écoute sur 0.0.0.0:9095. Écoute sur 0.0.0.0:9095.
POST /trigger lance auto-improve.py (run standard, MAX_FILES fichiers) Les POST /trigger* retournent immédiatement {"started": true}.
POST /trigger/all lance avec --all Le script tourne en background thread.
POST /trigger/subdir body JSON {"subdir": "ia"} lance sur un sous-dossier Polling du résultat via GET /status.
POST /trigger/dry-run analyse sans commit
GET /status dernier état (state.json) POST /trigger run standard (MAX_FILES fichiers)
GET /health {"ok": true} POST /trigger/subdir body JSON {"subdir": "ia"}
POST /trigger/dry-run analyse sans commit/push
POST /trigger/daily-pr crée PR GitHub + résumé journalier
GET /status dernier résultat + état running
GET /health {"ok": true}
""" """
import json import json
@ -27,11 +31,11 @@ _running = False
_last_result = None _last_result = None
def run_script(extra_args: list = None) -> dict: def run_script(extra_args: list = None):
global _running, _last_result global _running, _last_result
with _lock: with _lock:
if _running: if _running:
return {"error": "already_running"} return
_running = True _running = True
try: try:
@ -46,7 +50,6 @@ def run_script(extra_args: list = None) -> dict:
if result.stderr: if result.stderr:
log.write(f"STDERR: {result.stderr}") log.write(f"STDERR: {result.stderr}")
# Extraire le JSON du dernier bloc de stdout
out = result.stdout out = result.stdout
start = out.rfind("{") start = out.rfind("{")
end = out.rfind("}") + 1 end = out.rfind("}") + 1
@ -54,21 +57,27 @@ def run_script(extra_args: list = None) -> dict:
_last_result = json.loads(out[start:end]) _last_result = json.loads(out[start:end])
else: else:
_last_result = {"error": "no_json", "stdout": out[-500:]} _last_result = {"error": "no_json", "stdout": out[-500:]}
return _last_result
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
_last_result = {"error": "timeout"} _last_result = {"error": "timeout"}
return _last_result
except Exception as e: except Exception as e:
_last_result = {"error": str(e)} _last_result = {"error": str(e)}
return _last_result
finally: finally:
_running = False _running = False
def trigger_async(extra_args: list = None) -> dict:
with _lock:
if _running:
return {"started": False, "error": "already_running"}
t = threading.Thread(target=run_script, args=(extra_args,), daemon=True)
t.start()
return {"started": True, "running": True}
class Handler(BaseHTTPRequestHandler): class Handler(BaseHTTPRequestHandler):
def log_message(self, fmt, *args): def log_message(self, fmt, *args):
pass # silencer les logs HTTP par défaut pass
def send_json(self, code: int, data: dict): def send_json(self, code: int, data: dict):
body = json.dumps(data, ensure_ascii=False, indent=2).encode() body = json.dumps(data, ensure_ascii=False, indent=2).encode()
@ -82,41 +91,36 @@ class Handler(BaseHTTPRequestHandler):
if self.path == "/health": if self.path == "/health":
self.send_json(200, {"ok": True, "running": _running}) self.send_json(200, {"ok": True, "running": _running})
elif self.path == "/status": elif self.path == "/status":
if STATE_FILE.exists(): state = json.loads(STATE_FILE.read_text()) if STATE_FILE.exists() else None
state = json.loads(STATE_FILE.read_text()) self.send_json(200, {"state": state, "last_result": _last_result, "running": _running})
self.send_json(200, {"state": state, "last_result": _last_result, "running": _running})
else:
self.send_json(200, {"state": None, "last_result": _last_result, "running": _running})
else: else:
self.send_json(404, {"error": "not_found"}) self.send_json(404, {"error": "not_found"})
def do_POST(self): def do_POST(self):
length = int(self.headers.get("Content-Length", 0)) length = int(self.headers.get("Content-Length", 0))
body = json.loads(self.rfile.read(length)) if length > 0 else {} body = json.loads(self.rfile.read(length)) if length > 0 else {}
if self.path == "/trigger": if self.path == "/trigger":
result = run_script() self.send_json(200, trigger_async())
self.send_json(200, result)
elif self.path == "/trigger/all": elif self.path == "/trigger/all":
result = run_script(["--all"]) self.send_json(200, trigger_async(["--all"]))
self.send_json(200, result)
elif self.path == "/trigger/subdir": elif self.path == "/trigger/subdir":
subdir = body.get("subdir") subdir = body.get("subdir")
if not subdir: if not subdir:
self.send_json(400, {"error": "missing 'subdir' in body"}) self.send_json(400, {"error": "missing 'subdir' in body"})
return return
result = run_script(["--subdir", subdir]) self.send_json(200, trigger_async(["--subdir", subdir]))
self.send_json(200, result)
elif self.path == "/trigger/dry-run": elif self.path == "/trigger/dry-run":
result = run_script(["--dry-run"]) self.send_json(200, trigger_async(["--dry-run"]))
self.send_json(200, result)
elif self.path == "/trigger/daily-pr":
self.send_json(200, trigger_async(["--daily-pr"]))
elif self.path == "/trigger/reset": elif self.path == "/trigger/reset":
result = run_script(["--reset"]) self.send_json(200, trigger_async(["--reset"]))
self.send_json(200, result)
else: else:
self.send_json(404, {"error": "not_found"}) self.send_json(404, {"error": "not_found"})