Rollback ciblé (revert des seuls commits [ka*], plus jamais de reset/revert de plage) + pousseur launchd com.ka.pousseur (push binaires Apple, contourne LNP) + consignes: push raté en mission = attendu
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
4 changed files +110 −22
modified
deploy/deploy.sh
+47 −1
@@ -138,10 +138,56 @@ PLIST | |||||
| 138 | done | 138 | done | ||
| 139 | } | 139 | } | ||
| 140 | 140 | ||||
| 141 | +deploy_pousseur() { | ||||
| 142 | + # Pousseur launchd (binaires Apple) : pousse les commits en attente des repos | ||||
| 143 | + # Ka du nœud — les missions Claude ne peuvent pas pousser elles-mêmes | ||||
| 144 | + # (macOS 26 Local Network Privacy bloque les descendants du runner python). | ||||
| 145 | + typeset -A REPOS | ||||
| 146 | + REPOS[M3U96a]='~/apps/vrai-prix | ||||
| 147 | +~/apps/sorti-ka | ||||
| 148 | +~/apps/job-ka' | ||||
| 149 | + REPOS[M3U96b]='~/apps/groupe-ka | ||||
| 150 | +~/apps/lou-ka | ||||
| 151 | +/opt/api-ka | ||||
| 152 | +~/apps/crea-ka | ||||
| 153 | +~/apps/resto-ka' | ||||
| 154 | + REPOS[M4M64a]='~/apps/immo-ka | ||||
| 155 | +~/fabri-ka' | ||||
| 156 | + REPOS[M4M64b]='~/auto-ka | ||||
| 157 | +~/apps/food-ka' | ||||
| 158 | + for n in $RUNNER_NODES; do | ||||
| 159 | + echo "=== pousseur → $n" | ||||
| 160 | + scp -q "$ROOT/deploy/pousseur.sh" $n:ka-pousseur.sh | ||||
| 161 | + ssh $n "chmod +x ~/ka-pousseur.sh | ||||
| 162 | + cat > ~/.ka-pousseur-repos <<'REPOS' | ||||
| 163 | +${REPOS[$n]} | ||||
| 164 | +REPOS | ||||
| 165 | + cat > ~/Library/LaunchAgents/com.ka.pousseur.plist <<'PLIST' | ||||
| 166 | +<?xml version=\"1.0\" encoding=\"UTF-8\"?> | ||||
| 167 | +<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\"> | ||||
| 168 | +<plist version=\"1.0\"><dict> | ||||
| 169 | + <key>Label</key><string>com.ka.pousseur</string> | ||||
| 170 | + <key>ProgramArguments</key><array> | ||||
| 171 | + <string>/bin/zsh</string> | ||||
| 172 | + <string>/Users/simon-pierreboucher/ka-pousseur.sh</string> | ||||
| 173 | + </array> | ||||
| 174 | + <key>RunAtLoad</key><true/><key>KeepAlive</key><true/> | ||||
| 175 | + <key>StandardOutPath</key><string>/Users/simon-pierreboucher/ka-guardian-spool/pousseur.log</string> | ||||
| 176 | + <key>StandardErrorPath</key><string>/Users/simon-pierreboucher/ka-guardian-spool/pousseur.log</string> | ||||
| 177 | +</dict></plist> | ||||
| 178 | +PLIST | ||||
| 179 | + mkdir -p ~/ka-guardian-spool | ||||
| 180 | + launchctl unload ~/Library/LaunchAgents/com.ka.pousseur.plist 2>/dev/null || true | ||||
| 181 | + launchctl load ~/Library/LaunchAgents/com.ka.pousseur.plist" \ | ||||
| 182 | + && echo " ✓ $n pousseur ok" || echo " ✗ $n pousseur KO" | ||||
| 183 | + done | ||||
| 184 | +} | ||||
| 185 | + | ||||
| 141 | case "${1:-all}" in | 186 | case "${1:-all}" in | ||
| 142 | courier) deploy_courier ;; | 187 | courier) deploy_courier ;; | ||
| 143 | runners) deploy_runners ;; | 188 | runners) deploy_runners ;; | ||
| 144 | orchestrators) deploy_orchestrators ;; | 189 | orchestrators) deploy_orchestrators ;; | ||
| 145 | − all) deploy_courier; deploy_runners; deploy_orchestrators ;; | 190 | + pousseur) deploy_pousseur ;; | ||
| 191 | + all) deploy_courier; deploy_runners; deploy_orchestrators; deploy_pousseur ;; | ||||
| 146 | esac | 192 | esac | ||
| 147 | echo "terminé." | 193 | echo "terminé." | ||
added
deploy/pousseur.sh
+34 −0
@@ -0,0 +1,34 @@ | |||||
| 1 | +#!/bin/zsh | ||||
| 2 | +# ============================================ | ||||
| 3 | +# KA pousseur — pousse vers spbgit les commits en attente des repos Ka du nœud. | ||||
| 4 | +# Chaîne 100 % binaires Apple (launchd → zsh → /usr/bin/git → /usr/bin/ssh) : | ||||
| 5 | +# contourne macOS 26 Local Network Privacy, qui bloque les `git push` lancés | ||||
| 6 | +# par les missions Claude (descendants du runner python homebrew). | ||||
| 7 | +# Repos surveillés : un chemin par ligne dans ~/.ka-pousseur-repos. | ||||
| 8 | +# Push SIMPLE uniquement (jamais de force) : un non-fast-forward est loggé, | ||||
| 9 | +# jamais résolu d'autorité. | ||||
| 10 | +# ============================================ | ||||
| 11 | +export PATH=/usr/bin:/bin:/usr/sbin:/sbin | ||||
| 12 | +SPOOL=~/ka-guardian-spool | ||||
| 13 | +LOG=$SPOOL/pousseur.log | ||||
| 14 | +REPOS_FILE=~/.ka-pousseur-repos | ||||
| 15 | +mkdir -p $SPOOL | ||||
| 16 | + | ||||
| 17 | +while true; do | ||||
| 18 | + if [[ -f $REPOS_FILE ]]; then | ||||
| 19 | + while IFS= read -r dir; do | ||||
| 20 | + [[ -z $dir || $dir == \#* ]] && continue | ||||
| 21 | + d=${dir/#\~/$HOME} | ||||
| 22 | + [[ -d $d/.git ]] || continue | ||||
| 23 | + ahead=$(/usr/bin/git -C "$d" rev-list --count origin/main..main 2>/dev/null) | ||||
| 24 | + if [[ -n $ahead && $ahead -gt 0 ]]; then | ||||
| 25 | + if /usr/bin/git -C "$d" push origin main >>$LOG 2>&1; then | ||||
| 26 | + echo "$(date '+%F %T') poussé $ahead commit(s): $d" >>$LOG | ||||
| 27 | + else | ||||
| 28 | + echo "$(date '+%F %T') ÉCHEC push ($ahead en attente): $d" >>$LOG | ||||
| 29 | + fi | ||||
| 30 | + fi | ||||
| 31 | + done <$REPOS_FILE | ||||
| 32 | + fi | ||||
| 33 | + sleep 300 | ||||
| 34 | +done | ||||
modified
orchestrator/main.py
+2 −2
@@ -236,7 +236,7 @@ Le venv Python et/ou node_modules du repo sont déjà installés — utilise-les | |||||
| 236 | 6. RE-TESTE réellement: le connecteur doit rapporter un volume plausible (ordre de grandeur de la médiane {health.get('median_found')}). INTERDIT d'inventer, stubber ou câbler des données en dur. Un test qui retourne 0 ou 3 items quand la médiane est {health.get('median_found')} N'EST PAS une réparation. | 236 | 6. RE-TESTE réellement: le connecteur doit rapporter un volume plausible (ordre de grandeur de la médiane {health.get('median_found')}). INTERDIT d'inventer, stubber ou câbler des données en dur. Un test qui retourne 0 ou 3 items quand la médiane est {health.get('median_found')} N'EST PAS une réparation. | ||
| 237 | 7. REDÉMARRE uniquement le process concerné: `pm2 restart {sync_proc}`. Puis vérifie que le site répond: `curl -s -o /dev/null -w '%{{http_code}}' http://localhost:{svc['web_port']}/` (attendu: 200/3xx). | 237 | 7. REDÉMARRE uniquement le process concerné: `pm2 restart {sync_proc}`. Puis vérifie que le site répond: `curl -s -o /dev/null -w '%{{http_code}}' http://localhost:{svc['web_port']}/` (attendu: 200/3xx). | ||
| 238 | 8. COMMITTE: `git add -A && git commit -m "[{AGENT}] fix connecteur {source}: <résumé court>"`. Si tu as touché plusieurs fichiers pour des raisons distinctes, fais des commits séparés et clairs. | 238 | 8. COMMITTE: `git add -A && git commit -m "[{AGENT}] fix connecteur {source}: <résumé court>"`. Si tu as touché plusieurs fichiers pour des raisons distinctes, fais des commits séparés et clairs. | ||
| 239 | −9. POUSSE sur spbgit (OBLIGATOIRE — le push fait partie du travail): `git push origin main`. Vérifie qu'il réussit; en cas d'échec réseau, re-tente une fois après 30 s. S'il échoue encore, dis-le explicitement dans ton verdict final (champ actions) — mais ne modifie JAMAIS ~/.ssh ni la config git pour « réparer » le push. | 239 | +9. POUSSE sur spbgit (le push fait partie du travail): `git push origin main`. NOTE: sur certains nœuds le push échoue en « no route to host » depuis une mission (macOS 26 Local Network Privacy) — c'est ATTENDU: le pousseur launchd (com.ka.pousseur) poussera tes commits dans les 5 minutes. Si le push échoue, signale-le simplement dans ton rapport final et passe à la suite; n'insiste pas et ne modifie JAMAIS ~/.ssh ni la config git pour « réparer » le push. | ||
| 240 | 240 | ||||
| 241 | == INTERDITS ABSOLUS == | 241 | == INTERDITS ABSOLUS == | ||
| 242 | Toucher aux autres connecteurs ou aux autres apps du nœud; modifier la config pm2/ngrok/launchd ou le schéma de la base; installer des paquets globaux; supprimer des données; `git push --force` ou push d'une autre branche que main; toucher à ~/.ssh, aux clés API ou aux fichiers hors du repo. Ne désactive JAMAIS un connecteur pour « réparer » sa santé. Si la source est définitivement morte (site fermé, domaine à vendre, 404 permanent confirmé), ne force rien: documente et conclus en verdict site_source_mort. | 242 | Toucher aux autres connecteurs ou aux autres apps du nœud; modifier la config pm2/ngrok/launchd ou le schéma de la base; installer des paquets globaux; supprimer des données; `git push --force` ou push d'une autre branche que main; toucher à ~/.ssh, aux clés API ou aux fichiers hors du repo. Ne désactive JAMAIS un connecteur pour « réparer » sa santé. Si la source est définitivement morte (site fermé, domaine à vendre, 404 permanent confirmé), ne force rien: documente et conclus en verdict site_source_mort. | ||
@@ -253,7 +253,7 @@ COMMON_RULES = """== RÈGLES COMMUNES (non négociables) == | |||||
| 253 | - Utilise le venv/node_modules du repo; jamais d'installation globale. | 253 | - Utilise le venv/node_modules du repo; jamais d'installation globale. | ||
| 254 | - INTERDIT d'inventer, stubber ou câbler des données en dur. Chaque donnée vient d'un vrai fetch. | 254 | - INTERDIT d'inventer, stubber ou câbler des données en dur. Chaque donnée vient d'un vrai fetch. | ||
| 255 | - INTERDITS: toucher aux autres apps du nœud, config pm2/ngrok/launchd, schéma de la base (sauf migration prévue par le repo), `git push --force` ou push d'une autre branche que main, ~/.ssh, clés API, suppression de données. | 255 | - INTERDITS: toucher aux autres apps du nœud, config pm2/ngrok/launchd, schéma de la base (sauf migration prévue par le repo), `git push --force` ou push d'une autre branche que main, ~/.ssh, clés API, suppression de données. | ||
| 256 | −- À la fin: `pm2 restart <process de sync>`, vérifie que le site répond (curl localhost), committe en messages clairs préfixés [%AGENT%], puis POUSSE sur spbgit (OBLIGATOIRE): `git push origin main` — re-tente une fois après 30 s si échec réseau, et signale tout push raté dans ton rapport final.""" | 256 | +- À la fin: `pm2 restart <process de sync>`, vérifie que le site répond (curl localhost), committe en messages clairs préfixés [%AGENT%], puis POUSSE sur spbgit: `git push origin main`. Un échec « no route to host » est ATTENDU depuis une mission (macOS 26 LNP) — le pousseur launchd s'en chargera dans les 5 minutes: signale-le et passe à la suite, sans toucher à ~/.ssh ni à la config git.""" | ||
| 257 | 257 | ||||
| 258 | 258 | ||||
| 259 | def effort_stack(service: str) -> str: | 259 | def effort_stack(service: str) -> str: | ||
modified
runner/runner.py
+27 −19
@@ -334,30 +334,38 @@ class RollbackIn(BaseModel): | |||||
| 334 | def rollback(r: RollbackIn, x_ka_token: str | None = Header(default=None)) -> dict[str, Any]: | 334 | def rollback(r: RollbackIn, x_ka_token: str | None = Header(default=None)) -> dict[str, Any]: | ||
| 335 | """Revient au commit d'avant mission (reset --hard si tous les commits sont de l'agent, sinon revert).""" | 335 | """Revient au commit d'avant mission (reset --hard si tous les commits sont de l'agent, sinon revert).""" | ||
| 336 | check_token(x_ka_token) | 336 | check_token(x_ka_token) | ||
| 337 | − log = git(r.dir, "log", "--format=%s", f"{r.base_commit}..HEAD").stdout.strip() | 337 | + log = git(r.dir, "log", "--format=%H %s", f"{r.base_commit}..HEAD").stdout.strip() | ||
| 338 | − msgs = log.splitlines() if log else [] | 338 | + entries = [ln.split(" ", 1) for ln in log.splitlines() if ln.strip()] | ||
| 339 | − if not msgs: | 339 | + agent_shas = [h for h, s in entries if s.startswith("[ka")] | ||
| 340 | + if not entries: | ||||
| 340 | mode = "aucun_commit" | 341 | mode = "aucun_commit" | ||
| 341 | − elif all(s.startswith("[ka") for s in msgs): | ||||
| 342 | − git(r.dir, "reset", "--hard", r.base_commit) | ||||
| 343 | − mode = "reset_hard" | ||||
| 344 | else: | 342 | else: | ||
| 345 | − rc = git(r.dir, "revert", "--no-edit", f"{r.base_commit}..HEAD") | 343 | + # Revert CIBLÉ, du plus récent au plus ancien, UNIQUEMENT les commits de | ||
| 346 | − if rc.returncode != 0: | 344 | + # l'agent — jamais reset --hard ni revert de plage : les commits étrangers | ||
| 347 | − git(r.dir, "revert", "--abort") | 345 | + # (docs, humains, autres sessions) doivent survivre au rollback (leçon | ||
| 348 | − git(r.dir, "reset", "--hard", r.base_commit) | 346 | + # fabri-ka 2026-08-24 : un revert de plage avait emporté la page /doc et | ||
| 349 | − mode = "reset_hard(fallback)" | 347 | + # un connecteur), et les commits déjà poussés sur spbgit ne doivent | ||
| 350 | − else: | 348 | + # jamais exiger de force-push. | ||
| 351 | − mode = "revert" | 349 | + reverted: list[str] = [] | ||
| 350 | + skipped: list[str] = [] | ||||
| 351 | + for sha in agent_shas: # git log liste déjà du plus récent au plus ancien | ||||
| 352 | + rc = git(r.dir, "revert", "--no-edit", sha) | ||||
| 353 | + if rc.returncode != 0: | ||||
| 354 | + git(r.dir, "revert", "--abort") | ||||
| 355 | + skipped.append(sha[:9]) | ||||
| 356 | + else: | ||||
| 357 | + reverted.append(sha[:9]) | ||||
| 358 | + mode = f"revert_cible({len(reverted)}/{len(agent_shas)})" | ||||
| 359 | + if skipped: | ||||
| 360 | + mode += f" sautés:{','.join(skipped)}" | ||||
| 352 | # Pas de git clean : les process de sync de l'app écrivent en continu, | 361 | # Pas de git clean : les process de sync de l'app écrivent en continu, | ||
| 353 | # on ne supprime jamais de fichiers non trackés apparus pendant la mission. | 362 | # on ne supprime jamais de fichiers non trackés apparus pendant la mission. | ||
| 354 | − # Les agents poussent leurs commits sur spbgit → le rollback doit aussi | 363 | + # Push simple (les reverts s'empilent, fast-forward). Échec attendu sous | ||
| 355 | − # être poussé, sinon origin reste en avance sur le nœud. | 364 | + # macOS 26 Local Network Privacy (runner = python) : le pousseur launchd | ||
| 365 | + # (com.ka.pousseur, chaîne binaires Apple) poussera dans les 5 minutes. | ||||
| 356 | pushed = None | 366 | pushed = None | ||
| 357 | − if mode != "aucun_commit": | 367 | + if entries: | ||
| 358 | − p = git(r.dir, "push", "--force-with-lease", "origin", "HEAD") | 368 | + p = git(r.dir, "push", "origin", "HEAD") | ||
| 359 | − if p.returncode != 0: | ||||
| 360 | − p = git(r.dir, "push", "--force", "origin", "HEAD") | ||||
| 361 | pushed = p.returncode == 0 | 369 | pushed = p.returncode == 0 | ||
| 362 | for name in r.pm2: | 370 | for name in r.pm2: | ||
| 363 | sh(f"pm2 restart {shlex.quote(name)} --update-env", timeout=180) | 371 | sh(f"pm2 restart {shlex.quote(name)} --update-env", timeout=180) | ||
| 364 | 372 | ||||