SPB Git forge

spb/ka-guardian

Public
26commits 1branches 0releases
4.5 MBsize
maindefault branch
19 days agolast push
Python 57.1% Shell 13.8% CSS 12% JavaScript 10% HTML 7.2%

veilleur de sites: surveillance up/down des sites publics par agent

- topology.json: service vraiprix (M3U96a:8090, site seulement) ajouté a ka6;
  politique du veilleur (intervalle 120s, 2 echecs, throttle restart 10 min,
  fenetres watching/cooldown courtes pour _site)
- orchestrator/main.py: site_engine — check du site public de chaque service,
  down confirme → pm2 restart auto via runner /restart, toujours down →
  incident site_down (priorite max) + mission Claude d investigation
  (build_site_down_prompt); current_status/_site, resolution auto au retour
  en ligne, garde anti-boucle abandonnes 24h; /api/state expose sites
- runner/runner.py: endpoint POST /restart (pm2 restart + healthcheck, sans git)
- app.js: libelle site_down; CLAUDE.md: doc du veilleur

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed 1 mo ago (Aug 25, 2026) parent 5e3f030

5 changed files +237 −9

modified CLAUDE.md +15 −2
@@ -1,9 +1,22 @@
1 −# KA Guardian — ka2 · ka4 · ka6 (agents gardiens des connecteurs)
1 +# KA Guardian — ka2 · ka4 · ka6 (agents gardiens des connecteurs ET des sites)
2 2
3 3 Un seul repo pour les **3 sites gardiens** : www.ka2.bot, www.ka4.bot, www.ka6.bot.
4 4 Chaque agent est une instance de `orchestrator/main.py` (env `AGENT=ka2|ka4|ka6`),
5 5 identité (accent, tagline, services surveillés) dans `topology.json`.
6 6
7 +## Veilleur de sites (2026-08-25)
8 +
9 +En plus des connecteurs (scan api-ka), chaque agent surveille le **site public**
10 +de chacun de ses services (`site_engine` dans main.py, source d'incident `_site`) :
11 +
12 +1. GET du `site` de chaque service aux `site_check_interval_seconds` (120 s) ; down = ≥500/timeout, confirmé par un 2ᵉ essai puis `site_check_fails` (2) cycles consécutifs.
13 +2. Down confirmé → **pm2 restart automatique** via l'endpoint `/restart` du runner du nœud (throttle `site_restart_throttle_seconds`), re-check du site.
14 +3. Toujours down → incident `site_down` (priorité max au dispatch) → **mission Claude d'investigation** (`build_site_down_prompt`) sur le nœud : logs pm2, cause racine (crash, port, build, DB, disque, tunnel ngrok), correctif minimal, commit+push.
15 +4. Cycle standard ensuite : watching (fenêtre courte `site_watch_window_hours`), rollback si faux « repare », cooldown `site_cooldown_hours`, max 3 tentatives puis abandon (re-examen après `site_abandoned_retry_hours`). Les restarts automatiques continuent pendant les cooldowns.
16 +
17 +`vraiprix` (M3U96a, port 8090) est un service **site seulement** de ka6 : aucun
18 +connecteur sous api-ka, seule la surveillance du site s'applique.
19 +
7 20 ## Production (nœud M4M36)
8 21
9 22 - Répertoire : `~/cluster-projects/ka-guardian` (ce repo, origin = gitsrv/spbgit `ka-guardian.git`).
@@ -17,7 +30,7 @@ identité (accent, tagline, services surveillés) dans `topology.json`.
17 30 2. Vérifier : `curl -sf localhost:8799/health` (8899, 8999) puis le site public.
18 31 3. `git add … && git commit && git push origin main` (origin = gitsrv/spbgit, PAS GitHub).
19 32
20 −⚠️ NE JAMAIS redémarrer les **runners** (`com.ka.guardian-runner` sur M3U96a/b, M4M64a/b) : ça tue la mission Claude en vol. Ils ne vivent pas sur ce nœud de toute façon.
33 +⚠️ NE JAMAIS redémarrer les **runners** (`com.ka.guardian-runner` sur M3U96a/b, M4M64a/b) pendant qu'ils sont occupés : ça tue la mission Claude en vol. Pour redéployer `runner/runner.py`, vérifier d'abord `curl localhost:7791/health` (`busy:false`) sur chaque nœud, comme le fait `deploy/deploy.sh runners`.
21 34 ⚠️ Le trafic LAN direct est bloqué par macOS 26 (Local Network Privacy) — le courrier zsh `com.ka.guardian-courier` s'en occupe ; ne pas le contourner.
22 35
23 36 ## Pages & image de marque
modified orchestrator/main.py +178 −5
@@ -348,6 +348,42 @@ Termine ta TOUTE DERNIÈRE réponse par un bloc JSON:
348 348 {{"verdict": "livre|echec", "diagnostic": "état initial constaté", "actions": "enrichissements livrés", "test": "avant/après mesuré", "fichiers": ["fichiers modifiés"]}}"""
349 349
350 350
351 +def build_site_down_prompt(service: str, detail: dict[str, Any]) -> str:
352 + svc = SERVICES[service]
353 + web_proc = svc["pm2"][0]
354 + node_alias = NODES[svc["node"]]["alias"]
355 + return f"""Tu es {AGENT}, agent gardien autonome du Groupe KA. MISSION URGENTE: le SITE {svc.get('site', '')} de l'app {svc['app']} NE RÉPOND PLUS, et le redémarrage pm2 automatique n'a pas suffi. Investigue, trouve la cause racine et remets le site en ligne.
356 +Session 100 % AUTONOME: tu ne poses aucune question, tu travailles jusqu'au bout.
357 +
358 +== CE QUE LE VEILLEUR A DÉJÀ CONSTATÉ ==
359 +- échecs consécutifs du check public: {detail.get('fails', '?')} (site muet depuis {detail.get('down_since', '?')})
360 +- dernière erreur du check public: {detail.get('error', '?')}
361 +- redémarrage pm2 automatique: {detail.get('restart', 'tenté')} — santé locale mesurée juste après: {jdump(detail.get('health')) if detail.get('health') else 'inconnue'}
362 +
363 +== TON ENVIRONNEMENT ==
364 +Tu es sur le nœud {node_alias}, répertoire courant = repo de l'app: {svc['dir']} (remote-first, source de vérité).
365 +Process pm2 de l'app: {', '.join(svc['pm2'])} (web: {web_proc}); le site local doit répondre sur http://localhost:{svc['web_port']}/ et le site public est {svc.get('site', '')} (tunnel ngrok sur ce nœud, souvent un process pm2 `<app>-ngrok` ou un service launchd).
366 +
367 +== DÉMARCHE IMPOSÉE (dans l'ordre) ==
368 +1. CONSTATE: `curl -s -o /dev/null -w '%{{http_code}}' http://localhost:{svc['web_port']}/` puis la même chose sur {svc.get('site', 'le site public')}. `pm2 jlist` / `pm2 describe {web_proc}`: statut, nombre de restarts, uptime, mémoire.
369 +2. LIS LES LOGS: `pm2 logs {web_proc} --nostream --lines 300` (stdout ET err) + dossier logs/ du repo. Cherche: crash en boucle, port déjà occupé (EADDRINUSE), exception au démarrage, OOM, module manquant, build corrompu, base de données verrouillée/corrompue, disque plein (`df -h`).
370 +3. DISTINGUE les 3 familles de panne:
371 + a. Process web mort ou en boucle de crash → cause racine dans le code/l'env → corrige puis `pm2 restart {web_proc}`.
372 + b. Process online mais localhost:{svc['web_port']} muet ou en erreur → mauvais port/bind, build cassé → rebuild si le repo a une étape de build (npm run build…), puis redémarre.
373 + c. localhost OK mais site public muet → le tunnel: trouve le process (pm2 `*-ngrok` de CETTE app ou launchd) et relance-le (`pm2 restart <app>-ngrok` ou `launchctl kickstart -k gui/$(id -u)/<label>`). NE MODIFIE JAMAIS sa config ni son domaine.
374 +4. CORRIGE la cause racine de façon minimale (pas de rustine qui masque le symptôme). Si des données/DB sont corrompues, répare prudemment SANS perte de données (copie de sauvegarde avant toute opération risquée).
375 +5. VÉRIFIE pour de vrai: localhost ET le site public doivent répondre 200/3xx de façon STABLE (2 mesures espacées de 30 s).
376 +6. Si tu as modifié du code ou de la config du repo: `git add -A && git commit -m "[{AGENT}] fix site down {service}: <cause>"` puis `git push origin main`. Un échec « no route to host » au push est ATTENDU (macOS 26 LNP) — le pousseur launchd s'en charge dans les 5 minutes: signale-le et passe à la suite, sans toucher à ~/.ssh ni à la config git.
377 +
378 +== INTERDITS ABSOLUS ==
379 +Toucher aux autres apps du nœud; MODIFIER la config pm2/ngrok/launchd (relancer un process existant de CETTE app est permis, changer sa config non); supprimer des données; `git push --force` ou push d'une autre branche que main; ~/.ssh; clés API. Si la panne dépasse l'app (disque plein système, nœud malade), libère de l'espace UNIQUEMENT dans le repo de l'app (logs, caches, artefacts de build) et documente le reste dans ton diagnostic.
380 +
381 +== FIN DE MISSION ==
382 +Termine ta TOUTE DERNIÈRE réponse par un bloc JSON exactement de cette forme:
383 +{{"verdict": "repare|echec|rien_a_faire", "diagnostic": "cause racine en 1-2 phrases", "actions": "ce que tu as fait", "test": "codes HTTP local + public mesurés", "fichiers": ["fichiers modifiés"]}}
384 +Sois honnête: « repare » exige que le SITE PUBLIC réponde réellement — le veilleur re-vérifie aux 2 minutes et un faux « repare » sera rollback."""
385 +
386 +
351 387 async def dispatch(incident: sqlite3.Row, health: dict[str, Any]) -> None:
352 388 service, source, iid = incident["service"], incident["source"], incident["id"]
353 389 svc = SERVICES[service]
@@ -366,6 +402,9 @@ async def dispatch(incident: sqlite3.Row, health: dict[str, Any]) -> None:
366 402 timeout = min(timeout, int(health["max_minutes"]) * 60)
367 403 if health.get("max_cost_usd"):
368 404 max_cost = float(health["max_cost_usd"])
405 + elif kind == "site_down":
406 + prompt = build_site_down_prompt(service, health)
407 + max_turns, timeout = POLICY["mission_max_turns"], POLICY["mission_timeout_seconds"]
369 408 else:
370 409 prompt = build_prompt(service, source, health)
371 410 max_turns, timeout = POLICY["mission_max_turns"], POLICY["mission_timeout_seconds"]
@@ -518,8 +557,11 @@ async def tick() -> None:
518 557 asyncio.create_task(reconcile_mission(m["id"]))
519 558
520 559 # 2. Watching → rollback si la fenêtre est passée et toujours cassé
560 + # (fenêtre courte pour les incidents de site: le veilleur mesure aux
561 + # 2 minutes, pas besoin d'attendre le rythme des scans api-ka)
521 562 for inc in c.execute("SELECT * FROM incidents WHERE state='watching'").fetchall():
522 − if now() - inc["updated"] < POLICY["watch_window_hours"] * 3600:
563 + window_h = POLICY.get("site_watch_window_hours", 1) if inc["source"] == "_site" else POLICY["watch_window_hours"]
564 + if now() - inc["updated"] < window_h * 3600:
523 565 continue
524 566 status = current_status(inc["service"], inc["source"])
525 567 m = c.execute("SELECT * FROM missions WHERE incident_id=? AND base_commit IS NOT NULL "
@@ -536,7 +578,8 @@ async def tick() -> None:
536 578
537 579 # 3. Cooldown expiré → réouverture ou abandon
538 580 for inc in c.execute("SELECT * FROM incidents WHERE state='cooldown'").fetchall():
539 − if now() - inc["updated"] < POLICY["attempt_cooldown_hours"] * 3600:
581 + cool_h = POLICY.get("site_cooldown_hours", 1) if inc["source"] == "_site" else POLICY["attempt_cooldown_hours"]
582 + if now() - inc["updated"] < cool_h * 3600:
540 583 continue
541 584 status = current_status(inc["service"], inc["source"])
542 585 if status == "ok":
@@ -556,8 +599,8 @@ async def tick() -> None:
556 599 busy_nodes = {m["node"] for m in c.execute("SELECT node FROM missions WHERE state='running'").fetchall()}
557 600 nxt = c.execute(
558 601 "SELECT * FROM incidents WHERE state='open' "
559 − "ORDER BY CASE status_detected WHEN 'manual' THEN 0 WHEN 'effort_new' THEN 0 "
560 − "WHEN 'effort_enrich' THEN 0 WHEN 'broken' THEN 1 ELSE 2 END, created "
602 + "ORDER BY CASE status_detected WHEN 'site_down' THEN 0 WHEN 'manual' THEN 0 "
603 + "WHEN 'effort_new' THEN 0 WHEN 'effort_enrich' THEN 0 WHEN 'broken' THEN 1 ELSE 2 END, created "
561 604 ).fetchall()
562 605 for inc in nxt:
563 606 if inc["service"] not in SERVICES:
@@ -570,6 +613,11 @@ async def tick() -> None:
570 613
571 614
572 615 def current_status(service: str, source: str) -> str:
616 + if source == "_site":
617 + st = SITE.get(service) or {}
618 + if not st.get("checked"):
619 + return "inconnu"
620 + return "ok" if st.get("ok") else "broken"
573 621 block = LATEST["mine"].get(service) or {}
574 622 for conn in block.get("connectors", []):
575 623 if conn["source"] == source:
@@ -645,6 +693,130 @@ async def engine() -> None:
645 693 await asyncio.sleep(POLICY["poll_interval_seconds"])
646 694
647 695
696 +# ----------------------------------------------------- veilleur de sites ----
697 +# Surveillance du SITE WEB de chaque service assigné (indépendante des
698 +# connecteurs api-ka). Down confirmé → pm2 restart automatique via le runner
699 +# du nœud; toujours down → incident « site_down » et mission d'investigation.
700 +
701 +SITE: dict[str, dict[str, Any]] = {} # service → état du dernier check public
702 +
703 +
704 +async def check_site(url: str) -> tuple[bool, str]:
705 + try:
706 + async with httpx.AsyncClient(follow_redirects=True, timeout=15) as cl:
707 + r = await cl.get(url, headers={"User-Agent": f"ka-guardian-{AGENT}/1.0"})
708 + return r.status_code < 500, f"HTTP {r.status_code}"
709 + except Exception as exc:
710 + return False, f"{type(exc).__name__}: {exc}"
711 +
712 +
713 +async def runner_restart(service: str) -> dict[str, Any]:
714 + """pm2 restart de l'app via le runner de son nœud (endpoint /restart)."""
715 + svc = SERVICES[service]
716 + ip = NODES[svc["node"]]["lan_ip"]
717 + try:
718 + code, body = await lan_post(ip, RUNNER_PORT, "/restart",
719 + {"pm2": svc["pm2"], "web_port": svc["web_port"]}, timeout=240)
720 + return json.loads(body) if code == 200 else {"ok": False, "erreur": f"runner {code}: {body[:200]}"}
721 + except Exception as exc:
722 + return {"ok": False, "erreur": f"{type(exc).__name__}: {exc}"}
723 +
724 +
725 +async def site_check_service(service: str) -> None:
726 + svc = SERVICES[service]
727 + url = svc.get("site")
728 + if not url:
729 + return
730 + up, info = await check_site(url)
731 + if not up: # contre-mesure avant de compter l'échec (blip réseau/ngrok)
732 + await asyncio.sleep(5)
733 + up, info = await check_site(url)
734 + st = SITE.setdefault(service, {"fails": 0, "ok": True, "down_since": None, "last_restart": 0.0})
735 + st.update({"checked": now(), "ok": up, "info": info})
736 + if up:
737 + if st["fails"]:
738 + hub.publish_sync({"kind": "log", "level": "info",
739 + "msg": f"site {url} de retour en ligne ({info})", "ts": now()})
740 + st["fails"], st["down_since"] = 0, None
741 + with db() as c:
742 + inc = active_incident(c, service, "_site")
743 + if inc and inc["state"] in ("open", "cooldown"):
744 + set_incident(c, inc["id"], state="self_healed", resolved=now())
745 + incident_event(inc["id"], service, "_site", "self_healed", f"site de retour en ligne ({info})")
746 + return
747 + st["fails"] += 1
748 + st["down_since"] = st["down_since"] or now()
749 + need = POLICY.get("site_check_fails", 2)
750 + hub.publish_sync({"kind": "log", "level": "warn",
751 + "msg": f"site {url} muet ({info}) — échec {st['fails']}/{need}", "ts": now()})
752 + if st["fails"] < need:
753 + return
754 + with db() as c:
755 + inc = active_incident(c, service, "_site")
756 + if inc and inc["state"] in ("dispatched", "fixing"):
757 + return # une mission d'investigation travaille déjà sur cette app
758 + # Étape 1: pm2 restart automatique (throttlé pour ne pas marteler l'app)
759 + restart_res: dict[str, Any] = {"ok": False, "erreur": "non tenté (throttle)"}
760 + if now() - st["last_restart"] >= POLICY.get("site_restart_throttle_seconds", 600):
761 + st["last_restart"] = now()
762 + hub.publish_sync({"kind": "log", "level": "warn",
763 + "msg": f"{svc['app']}: site down confirmé → pm2 restart automatique ({', '.join(svc['pm2'])})",
764 + "ts": now()})
765 + restart_res = await runner_restart(service)
766 + await asyncio.sleep(POLICY.get("site_restart_wait_seconds", 20))
767 + up, info = await check_site(url)
768 + if up:
769 + st.update({"ok": True, "fails": 0, "down_since": None, "info": info})
770 + with db() as c:
771 + if inc:
772 + set_incident(c, inc["id"], state="resolved", resolved=now())
773 + incident_event(inc["id"], service, "_site", "resolved", f"site rétabli par pm2 restart ({info})")
774 + else:
775 + # Trace au dashboard: le restart automatique a suffi.
776 + iid = uuid.uuid4().hex[:10]
777 + c.execute("INSERT INTO incidents(id,service,source,status_detected,state,created,updated,resolved,detail) "
778 + "VALUES(?,?,?,?,?,?,?,?,?)",
779 + (iid, service, "_site", "site_down", "resolved", now(), now(), now(),
780 + jdump({"note": "rétabli par pm2 restart automatique", "restart": restart_res})))
781 + incident_event(iid, service, "_site", "resolved", f"site rétabli par pm2 restart automatique ({info})")
782 + return
783 + # Étape 2: toujours down → incident + mission d'investigation (si aucun actif)
784 + if inc is not None:
785 + return # open/watching: la machinerie d'incidents suit déjà son cours
786 + with db() as c:
787 + # Garde anti-boucle (même logique que les connecteurs, délai plus court:
788 + # un site down est urgent, on re-tente quand même chaque jour).
789 + ab = c.execute("SELECT updated FROM incidents WHERE service=? AND source='_site' "
790 + "AND state='abandoned' ORDER BY updated DESC LIMIT 1", (service,)).fetchone()
791 + if ab and now() - ab["updated"] < POLICY.get("site_abandoned_retry_hours", 24) * 3600:
792 + return
793 + detail = {"fails": st["fails"],
794 + "down_since": time.strftime("%Y-%m-%d %H:%M", time.localtime(st["down_since"])),
795 + "error": info,
796 + "restart": "pm2 restart exécuté, site toujours muet" if restart_res.get("ok")
797 + else f"restart en échec: {restart_res.get('erreur') or restart_res}",
798 + "health": restart_res.get("health")}
799 + iid = uuid.uuid4().hex[:10]
800 + c.execute("INSERT INTO incidents(id,service,source,status_detected,state,created,updated,detail) "
801 + "VALUES(?,?,?,?,?,?,?,?)",
802 + (iid, service, "_site", "site_down", "open", now(), now(), jdump(detail)))
803 + incident_event(iid, service, "_site", "open",
804 + f"site hors ligne malgré le pm2 restart automatique ({info}) — mission d'investigation demandée")
805 +
806 +
807 +async def site_engine() -> None:
808 + await asyncio.sleep(10)
809 + while True:
810 + if not LATEST["paused"]:
811 + for service in sorted(MY_SERVICES & set(SERVICES)):
812 + try:
813 + await site_check_service(service)
814 + except Exception as exc:
815 + hub.publish_sync({"kind": "log", "level": "warn",
816 + "msg": f"veilleur site {service}: {exc}", "ts": now()})
817 + await asyncio.sleep(POLICY.get("site_check_interval_seconds", 120))
818 +
819 +
648 820 # ------------------------------------------------------------------- app ---
649 821
650 822 app = FastAPI(title=f"KA Guardian — {AGENT}", docs_url=None, redoc_url=None)
@@ -654,6 +826,7 @@ app = FastAPI(title=f"KA Guardian — {AGENT}", docs_url=None, redoc_url=None)
654 826 async def startup() -> None:
655 827 init_db()
656 828 asyncio.create_task(engine())
829 + asyncio.create_task(site_engine())
657 830
658 831
659 832 def check_token(tok: str | None) -> None:
@@ -767,7 +940,7 @@ def state() -> dict[str, Any]:
767 940 "services": {s: {**SERVICES[s], "node_alias": NODES[SERVICES[s]["node"]]["alias"]} for s in ME["services"]},
768 941 "siblings": {a: {"domain": v["domain"], "accent": v["accent"], "services": v["services"]}
769 942 for a, v in TOPO["agents"].items() if a != AGENT},
770 − "latest": LATEST, "incidents": incidents, "missions": missions,
943 + "latest": LATEST, "sites": SITE, "incidents": incidents, "missions": missions,
771 944 "stats": dict(stats), "history": history, "policy": POLICY, "now": now(),
772 945 }
773 946
modified orchestrator/web/app.js +1 −0
@@ -7,6 +7,7 @@ const STATE_FR = { open: "à réparer", dispatched: "envoi…", fixing: "répara
7 7 cooldown: "attente", resolved: "résolu", self_healed: "auto-guéri", abandoned: "abandonné" };
8 8 const STATUSES = ["ok", "degraded", "broken", "stale"];
9 9 const STATUS_FR = { ok: "ok", degraded: "dégradé", broken: "cassé", stale: "endormi", manual: "manuel",
10 + site_down: "site hors ligne",
10 11 effort_new: "effort · nouveau connecteur", effort_enrich: "effort · enrichissement",
11 12 effort_degrade: "effort · inspection dégradés" };
12 13 const VERDICT_FR = { repare: "réparé", livre: "livré", echec: "échec", site_source_mort: "source morte",
modified runner/runner.py +22 −0
@@ -338,6 +338,28 @@ def mission_result(q: MissionResultIn, x_ka_token: str | None = Header(default=N
338 338 return {"status": "unknown"}
339 339
340 340
341 +class RestartIn(BaseModel):
342 + pm2: list[str]
343 + web_port: int
344 +
345 +
346 +@app.post("/restart")
347 +def restart(r: RestartIn, x_ka_token: str | None = Header(default=None)) -> dict[str, Any]:
348 + """Relance pm2 d'une app dont le site public ne répond plus (veilleur de
349 + sites des orchestrateurs). Léger et sans git — le retour arrière complet
350 + reste /rollback. Pas de verrou mission: relancer les process d'une app
351 + pendant qu'une mission travaille sur une AUTRE app du nœud est sans danger.
352 + """
353 + check_token(x_ka_token)
354 + restarted: dict[str, bool] = {}
355 + for name in r.pm2:
356 + p = sh(f"pm2 restart {shlex.quote(name)} --update-env", timeout=180)
357 + restarted[name] = p.returncode == 0
358 + time.sleep(8)
359 + h = healthcheck(r.web_port, r.pm2)
360 + return {"ok": all(restarted.values()) and h["ok"], "restarted": restarted, "health": h, "node": NODE}
361 +
362 +
341 363 class RollbackIn(BaseModel):
342 364 dir: str
343 365 base_commit: str
modified topology.json +21 −2
@@ -41,7 +41,8 @@
41 41 "services": [
42 42 "fabrika",
43 43 "jobka",
44 − "creaka"
44 + "creaka",
45 + "vraiprix"
45 46 ],
46 47 "default_for_unknown_services": true,
47 48 "accent_soft": "#f0e8fc"
@@ -164,6 +165,17 @@
164 165 ],
165 166 "web_port": 8096,
166 167 "site": "https://www.job-ka.com"
168 + },
169 + "vraiprix": {
170 + "app": "Vrai·Prix",
171 + "node": "m3u96a",
172 + "dir": "~/apps/vrai-prix",
173 + "pm2": [
174 + "vrai-prix"
175 + ],
176 + "web_port": 8090,
177 + "site": "https://www.vrai-prix.com",
178 + "comment": "Pas de connecteurs sous supervision api-ka (vrai-prix-ingest tourne à la demande): surveillance du SITE seulement. vrai-prix-ngrok exclu de pm2[] pour ne pas relancer le tunnel à chaque restart."
167 179 }
168 180 },
169 181 "policy": {
@@ -181,6 +193,13 @@
181 193 "effort_max_turns": 150,
182 194 "effort_timeout_seconds": 7200,
183 195 "abandoned_retry_hours": 168,
184 − "mass_incident_threshold": 20
196 + "mass_incident_threshold": 20,
197 + "site_check_interval_seconds": 120,
198 + "site_check_fails": 2,
199 + "site_restart_throttle_seconds": 600,
200 + "site_restart_wait_seconds": 20,
201 + "site_watch_window_hours": 1,
202 + "site_cooldown_hours": 1,
203 + "site_abandoned_retry_hours": 24
185 204 }
186 205 }
187 206