# Échecs partiels de commandes SSH : empêchez les agents de répéter le travail

Un agent IA doit traiter une commande SSH échouée comme une transition d'état inconnue, et non comme une autorisation à exécuter de nouveau le même texte. Une commande peut créer un utilisateur, recharger un service, puis échouer en écrivant un fichier final. Une nouvelle tentative aveugle peut créer un compte en double, écraser un réglage modifié à la main ou transformer un déploiement récupérable en panne.

Le conseil habituel, « vérifiez le code de sortie », est nécessaire mais incomplet. Le statut de sortie décrit la façon dont un processus s'est terminé. La récupération exige de conserver ce que le processus a terminé, l'état que l'hôte signale maintenant et l'opération que l'agent voulait effectuer. Placez ces informations dans un reçu de commande durable, puis demandez à l'agent de réconcilier l'état avant toute nouvelle action.

## Un échec SSH laisse trois inconnues différentes

Une action SSH échouée peut signifier que le shell distant a échoué, que la connexion a échoué ou que le contrôleur a cessé d'attendre. Ces cas exigent des traitements différents, mais les agents les réduisent souvent tous à « commande échouée ».

Prenons un script de déploiement distant qui effectue les opérations suivantes :

1. Il écrit une nouvelle archive de l'application dans un répertoire de versions.
2. Il fait pointer un lien symbolique `current` vers cette version.
3. Il redémarre le service.
4. Il lance un contrôle de santé, puis renvoie un code différent de zéro parce que le contrôle détecte une erreur temporaire de dépendance.

La nouvelle version est active alors que la commande a signalé un échec. Réexécuter le script peut être sans danger si chaque opération tolère la répétition. Mais le script crée plus souvent un nouveau répertoire de version, tronque un journal, renouvelle un identifiant ou exécute une migration. Le seul code de sortie ne permet pas de déterminer ce qui s'est produit.

Le deuxième cas est plus délicat : une interruption du transport SSH. Le processus local peut recevoir 255 après une coupure réseau alors que le shell distant continue son travail. Un délai d'attente du contrôleur pose le même problème. Le contrôleur sait seulement qu'il n'a pas reçu de réponse finale. Il ignore si la cible a reçu la requête, si le shell a démarré ou si le processus modifie encore l'état.

Cette distinction détermine l'action suivante de l'agent :

- Une sortie distante confirmée avec un reçu appelle une récupération fondée sur le checkpoint échoué.
- Une défaillance du transport appelle une observation avant toute modification.
- Un délai d'attente du contrôleur appelle une observation et, si nécessaire, une procédure d'annulation explicite plutôt qu'une requête en double.

Ne donnez pas à ces trois cas le nom de « nouvelles tentatives ». Une nouvelle tentative est une opération assortie d'une règle connue de répétition sans danger. Un état distant inconnu exige une réconciliation.

## Le statut de sortie décrit un processus, pas une transaction

Un code de sortie SSH fournit des éléments utiles, mais ce n'est pas un enregistrement de validation de base de données. Le manuel OpenSSH indique que `ssh` se termine avec le statut de la commande distante, ou avec 255 si une erreur s'est produite. Cette formulation trace une limite que de nombreux systèmes d'automatisation ignorent : un statut distant décrit la commande lorsque SSH le reçoit, tandis que 255 correspond au chemin d'erreur propre à SSH.

Un code de sortie nul doit lui aussi être interprété. Dans un shell POSIX, le statut d'une liste séquentielle simple vient normalement de la dernière commande. Ce script peut signaler une réussite après un échec important :

```sh
install -m 0644 app.conf /etc/myapp/app.conf
systemctl restart myapp
logger -t deploy "deployment finished"
```

Si `install` échoue mais que `systemctl restart` et `logger` renvoient zéro, le statut final du script est zéro. L'agent voit une réussite et peut affirmer à tort que la configuration a changé. Un dernier `echo done` produit le même mensonge.

Les pipelines ajoutent une autre voie d'échec. Dans Bash, le Bash Reference Manual précise que le statut d'un pipeline est celui de sa dernière commande, sauf si `pipefail` est activé. Cette commande peut renvoyer zéro si l'extraction se termine normalement après n'avoir reçu aucune donnée utile :

```bash
curl --fail --silent https://example.invalid/build.tar.gz | tar -xz -C /srv/myapp
```

Utilisez un interpréteur explicite et déclarez le comportement attendu :

```bash
#!/usr/bin/env bash
set -Eeuo pipefail

curl --fail --silent --show-error "$archive_url" | tar -xz -C "$release_dir"
```

`-e` demande à Bash de s'arrêter en cas de nombreux échecs non traités, `-u` refuse les variables non définies et `pipefail` conserve l'échec des éléments précédents du pipeline. L'option `E` permet au piège `ERR` de s'appliquer dans les fonctions et les substitutions de commandes. Ces réglages améliorent le signalement des échecs, mais ne rendent pas une séquence atomique.

Ce dernier point compte. `set -e` intervient après qu'une opération a renvoyé une erreur. Il ne peut pas supprimer un répertoire créé par une commande précédente ni restaurer un service redémarré auparavant. Cette option comporte aussi des exceptions surprenantes : les commandes testées par `if`, celles situées à gauche de `&&` ou `||` et plusieurs contextes composés ne provoquent pas toujours une sortie. Ajoutez des vérifications explicites autour des actions dont l'échec modifie la récupération.

## Définissez la limite de l'opération avant d'écrire la commande

Un agent ne peut pas récupérer une instruction vague comme « déployer le service ». La commande distante doit correspondre à une opération nommée, avec une postcondition qu'un observateur peut tester.

Pour un changement de version, l'opération peut être : « Faire pointer `/srv/myapp/current` vers la version `2025-04-18.3`, puis confirmer que le service actif signale cette version. » Pour une modification de base de données : « Appliquer exactement une fois la migration `add_invoice_index` et confirmer que son enregistrement existe. » Le texte de la commande est un détail d'implémentation. L'opération et sa postcondition déterminent si la récupération est possible.

Découpez une opération au niveau des limites irréversibles ou visibles de l'extérieur. Un checkpoint utile ne correspond pas à chaque ligne du shell. Enregistrez-en un après une modification d'état qui change la décision suivante. Pour un déploiement, cela peut inclure la vérification de l'archive, le remplissage du répertoire de version, le changement du lien symbolique, le redémarrage du service et l'observation de l'état de santé.

Évitez de recommander, à tort, de rendre chaque commande distante « idempotente » avant de la réessayer indéfiniment. L'idempotence s'applique à une opération précise et à un état souhaité défini. `mkdir -p /srv/app` peut être répétable. `useradd deploy` ne l'est que si l'agent vérifie que le compte existant possède l'UID, le groupe, le répertoire personnel et le shell attendus. `ALTER TABLE` peut échouer lors d'une deuxième exécution ou, pire, une migration mal écrite peut appliquer deux fois une modification apparentée.

Une commande peut être sûre à répéter alors que le workflow qui l'entoure ne l'est pas. Redémarrer un service peut être répétable, mais le redémarrer au milieu d'une copie de configuration peut exposer un fichier incomplet. Placez la validation de l'état à côté de l'action. Ne demandez pas à l'agent de la déduire d'une règle générale.

Pour chaque opération, définissez quatre champs avant d'accorder l'accès :

- Un identifiant d'opération conservé pendant toute la récupération.
- Une postcondition souhaitée qu'une commande en lecture seule peut examiner.
- Les checkpoints décrivant les modifications d'état terminées.
- Une action de récupération pour chaque checkpoint incomplet.

L'identifiant d'opération n'est pas décoratif. Si le contrôleur en crée un nouveau à chaque tentative, la cible ne peut pas distinguer une continuation d'une nouvelle requête. C'est ainsi que surviennent les migrations répétées et le provisionnement en double.

## Écrivez un reçu avant et après chaque modification d'état

Un reçu durable transforme un échec partiel en événement vérifiable. Écrivez-le sur l'hôte cible avant la première modification, mettez-le à jour après chaque checkpoint important et rendez les mises à jour atomiques.

Le script Bash suivant reste volontairement simple. Il déploie une version déjà préparée en changeant un lien symbolique et en redémarrant un service système. Il ne prétend pas couvrir toutes les méthodes de déploiement. Il montre la mécanique de reçu dont un agent a besoin.

```bash
#!/usr/bin/env bash
set -Eeuo pipefail

operation_id=${1:?operation ID required}
release=${2:?release path required}
service=${3:?service name required}
state_dir=/var/lib/agent-ops
receipt="$state_dir/$operation_id.receipt"
tmp="$receipt.$$"

mkdir -p "$state_dir"
chmod 0700 "$state_dir"

write_receipt() {
  cat >"$tmp" <<EOF
operation_id=$operation_id
release=$release
service=$service
checkpoint=$1
updated_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)
EOF
  chmod 0600 "$tmp"
  mv -f "$tmp" "$receipt"
}

fail() {
  status=$?
  write_receipt "failed:$status"
  exit "$status"
}
trap fail ERR

if [[ -f "$receipt" ]]; then
  . "$receipt"
  case "$checkpoint" in
    complete)
      printf 'operation already complete: %s\n' "$operation_id"
      exit 0
      ;;
    switched|restarted)
      printf 'operation requires reconciliation: %s\n' "$checkpoint" >&2
      exit 75
      ;;
  esac
fi

[[ -d "$release" ]]
write_receipt "release_verified"

ln -sfn "$release" /srv/myapp/current
write_receipt "switched"

systemctl restart "$service"
write_receipt "restarted"

active_target=$(readlink -f /srv/myapp/current)
[[ "$active_target" == "$release" ]]
systemctl is-active --quiet "$service"
write_receipt "complete"
printf 'operation complete: %s\n' "$operation_id"
```

Le fichier temporaire et `mv` sont importants. Sur un même système de fichiers, le renommage remplace le reçu en une seule opération. Un lecteur obtient donc soit l'ancien reçu complet, soit le nouveau, et jamais un fichier à moitié écrit. Rendez le répertoire d'état accessible en écriture uniquement au compte qui possède l'opération. Si un utilisateur non fiable peut modifier les reçus, la logique de récupération de l'agent prend une fiction pour une preuve.

Le piège `ERR` enregistre le statut de sortie lorsque Bash traite un échec. Il ne peut pas s'exécuter après une coupure de courant, un signal impossible à intercepter ou un arrêt brutal. C'est pourquoi le script enregistre sa progression après chaque modification d'état terminée au lieu de compter uniquement sur un piège final.

Ne sourcez pas de formats de reçus arbitraires comme le fait ce petit exemple, sauf si le répertoire possède des droits et un propriétaire stricts. En production, préférez du JSON traité par un analyseur connu ou un format de lignes fixe qui refuse les champs inattendus. L'exemple ne source qu'un fichier qu'il vient de créer dans un répertoire protégé, afin de garder le code shell lisible.

Le reçu doit décrire des faits observés, pas une intention optimiste. `checkpoint=switched` signifie que la commande du lien symbolique s'est terminée correctement. Cela ne signifie pas que le service a chargé la nouvelle version. `complete` vient après les vérifications explicites de la postcondition. Cette distinction empêche l'agent de prendre une commande écrite pour une opération achevée.

## Demandez à l'agent une réconciliation, pas une nouvelle commande

Après un résultat différent de zéro, l'agent doit conserver l'identifiant d'opération initial et lancer d'abord des vérifications en lecture seule. Il ne doit pas recréer la commande de déploiement en changeant légèrement sa formulation. Une nouvelle formulation ne crée pas une nouvelle transition d'état.

Pour le reçu de déploiement précédent, une commande de réconciliation peut examiner à la fois l'enregistrement durable et la postcondition active :

```bash
operation_id='release-7f3b'
cat "/var/lib/agent-ops/$operation_id.receipt"
printf 'current='
readlink -f /srv/myapp/current
systemctl is-active myapp
systemctl show myapp --property=ActiveState --property=SubState --no-pager
```

La sortie doit avoir une forme que l'agent peut analyser sans faire passer de la prose pour une preuve :

```text
operation_id=release-7f3b
release=/srv/myapp/releases/2025-04-18.3
service=myapp
checkpoint=restarted
updated_at=2025-04-18T14:05:12Z
current=/srv/myapp/releases/2025-04-18.3
active
ActiveState=active
SubState=running
```

Ici, le lien symbolique pointe vers la version demandée et le service est actif, mais le reçu s'est arrêté à `restarted`. Le processus distant a peut-être disparu après le redémarrage du service et avant l'écriture de `complete`. La procédure de récupération peut relancer les vérifications de la postcondition et, si elles réussissent, écrire un reçu de fin au moyen d'une commande de récupération strictement limitée. Elle ne doit pas recommencer le déploiement depuis le début.

Un protocole de contrôleur utile sépare la requête, le résultat et la récupération. Par exemple :

```json
{
  "operation_id": "release-7f3b",
  "action": "deploy_release",
  "target": "app-01",
  "arguments": {
    "release": "/srv/myapp/releases/2025-04-18.3",
    "service": "myapp"
  },
  "mode": "reconcile"
}
```

La cible ne doit accepter `mode: reconcile` que pour une inspection en lecture seule ou pour un chemin de fin préécrit qui vérifie la postcondition. Ne laissez pas l'agent envoyer une chaîne shell arbitraire marquée `reconcile`. Cette étiquette n'a aucune valeur de sécurité si la commande peut modifier quoi que ce soit.

Le code 75 de l'exemple signale volontairement un échec temporaire. Le nombre exact compte moins qu'un contrat documenté : l'agent comprend qu'il doit observer l'état et ne pas envoyer automatiquement une nouvelle tentative. Réservez des résultats distincts du contrôleur pour `completed`, `reconcile_required`, `rejected_before_start` et `transport_unknown`. Un simple booléen `success` détruit les informations nécessaires à la récupération.

## Les délais d'attente et les déconnexions exigent une preuve de l'état distant

Un délai d'attente est une observation locale. Le client local a cessé d'attendre, mais n'a pas forcément arrêté la commande distante. Prendre un délai pour une annulation est l'un des moyens les plus rapides de dupliquer une action distante.

Un contrôleur peut réduire l'ambiguïté en rendant les opérations exclusives. Avant une modification, le script distant crée un verrou exclusif associé à son identifiant d'opération. Une tentative ultérieure voit le verrou et choisit entre attendre, examiner le processus ou signaler qu'une décision humaine est nécessaire.

Pour un verrou simple au niveau de l'hôte, `flock` suffit souvent :

```bash
exec 9>/var/lib/agent-ops/deploy.lock
if ! flock -n 9; then
  printf 'another deployment operation is active\n' >&2
  exit 75
fi
```

Cela protège uniquement les processus qui respectent le même verrou. Cela ne protège pas contre un administrateur exécutant une procédure distincte et ne résout pas toutes les erreurs de conception de vos scripts. Pour une migration de base de données, utilisez le verrou consultatif ou le verrou de migration fourni par la base lorsque c'est possible. Pour une action d'API, utilisez un jeton d'idempotence pris en charge par cette API. Le verrou doit se trouver à côté de l'état qu'il protège.

Lorsque le contrôleur se reconnecte après un délai d'attente, examinez les éléments dans cet ordre :

1. Lisez le reçu de l'identifiant d'opération initial.
2. Vérifiez si le processus initial s'exécute encore, si l'opération possède un marqueur de processus fiable.
3. Testez la postcondition de l'opération avec des commandes en lecture seule.
4. Choisissez une action de récupération explicite ou faites remonter le cas si les observations se contredisent.

Ne faites pas de la présence du processus votre seul signal. Un processus peut exister tout en étant bloqué par une dépendance externe, tandis qu'un processus absent ne dit presque rien de ce qu'il a modifié avant de s'arrêter. Le reçu et la postcondition apportent des preuves plus solides.

Le multiplexage SSH demande la même prudence. Une connexion maître peut masquer l'échec d'une commande individuelle derrière un transport partagé, et un contrôleur peut confondre la fermeture d'un canal avec l'échec d'une opération. Capturez stdout, stderr, le code SSH brut, l'heure de début et l'heure de fin de la commande distante dans un même enregistrement d'action. Conservez stderr même si l'agent le résume. Les données brutes indiquent souvent si Bash a refusé une variable non définie, si une commande distante a renvoyé 75 ou si SSH lui-même a renvoyé 255.

## Certaines modifications exigent une compensation, pas une nouvelle tentative

De nombreuses opérations ne peuvent pas être rendues répétables sans danger après coup. La rotation d'identifiants, le nettoyage destructeur, les appels d'API assimilables à des paiements et les migrations de schéma exigent une procédure compensatoire ou une décision de l'opérateur.

Prenons une rotation d'identifiant. La commande peut créer un nouvel identifiant, mettre à jour un service, vérifier l'accès, puis révoquer l'ancien. Si elle échoue après la création mais avant la mise à jour du service, une nouvelle tentative peut créer encore un identifiant et laisser plusieurs secrets actifs. Le reçu doit enregistrer immédiatement l'identifiant du nouvel objet. La récupération peut ensuite vérifier quel identifiant le service utilise et décider de mettre à jour, de révoquer ou de conserver le nouveau.

Ne placez pas les secrets dans le reçu, la sortie standard ou le contexte de l'agent. Stockez uniquement un identifiant non secret ou une empreinte, à condition que cet identifiant ne donne pas lui-même accès. Le processus de récupération doit savoir quel objet existe, pas connaître sa valeur privée.

Les migrations de bases de données présentent un autre piège. La table d'historique d'un framework peut indiquer qu'une migration nommée est terminée sans décrire un remplissage de données interrompu exécuté hors de la transaction du framework. Écrivez les migrations de façon à séparer les vérifications de la modification de schéma, de la progression du remplissage et du marqueur de fin. Si la base prend en charge le DDL transactionnel pour votre opération, utilisez-le, mais ne supposez pas que chaque instruction DDL ou effet externe sera annulé avec la transaction.

Pour les actions visibles de l'extérieur, préférez un jeton d'idempotence d'API aux suppositions basées sur SSH. Si l'hôte distant appelle une API qui accepte une clé d'idempotence, persistez ce jeton dans le reçu avant la requête. Lors de la récupération, interrogez l'API avec le même jeton ou renvoyez la requête avec ce jeton, selon la sémantique documentée de l'API. Générer un nouveau jeton à chaque tentative de l'agent annule l'intérêt de cette fonction.

La règle est simple : si vous ne pouvez pas expliquer comment déterminer si une action a eu lieu, ne donnez pas à un agent autonome l'autorisation de la réessayer. Demandez à une personne d'inspecter la cible ou repensez l'opération autour d'un enregistrement d'état durable.

## Les scripts shell ont besoin d'un contrat que l'agent peut appliquer

Un script destiné à un agent doit exposer un contrat étroit et lisible par machine. Les agents reconstituent mal l'état à partir de journaux bavards, de sorties de terminal colorées et d'un mélange d'avertissements et de messages de réussite.

Utilisez des catégories de sortie stables, un seul objet de résultat et des identifiants d'opération explicites. Par exemple, écrivez une dernière ligne JSON uniquement lorsque la commande connaît son résultat :

```json
{"operation_id":"release-7f3b","outcome":"reconcile_required","checkpoint":"switched","exit_code":75}
```

Conservez les diagnostics ordinaires sur stderr et réservez stdout à l'enregistrement de résultat si votre contrôleur peut imposer cette convention. Une commande shell qui écrit simultanément des bannières, des barres de progression et du JSON sur stdout invite les erreurs d'analyse. Si la commande diffuse une progression utile, écrivez d'abord le reçu durable et considérez l'enregistrement structuré final comme une commodité, pas comme l'unique preuve.

Ne laissez pas l'agent choisir des noms de checkpoints, des chemins de reçus, des noms de services ou des interpréteurs arbitraires. Exposez une commande vérifiée avec des arguments contraints. Un wrapper qui accepte un shell libre après `--` ne fait que déplacer le problème derrière une étiquette plus propre.

Le contrat doit aussi indiquer quels échecs peuvent être réessayés sans danger. Un téléchargement de paquet peut renvoyer une erreur réseau réessayable avant toute modification de l'hôte. Un changement de lien symbolique suivi d'une perte de connexion ne peut pas être réessayé tant que la réconciliation n'a pas vérifié la cible du lien. Cette classification relève de l'auteur de l'opération, qui en connaît les effets, et non d'un modèle qui tente de deviner à partir de stderr.

Utilisez un hôte de test et interrompez le script à chaque checkpoint. Envoyez un signal de terminaison pendant l'extraction de l'archive, après le changement du lien symbolique, pendant le redémarrage et après le contrôle de santé final. Lancez ensuite le chemin de réconciliation et vérifiez qu'il prend la bonne décision. Si vous n'avez jamais interrompu volontairement une opération, vous ne savez pas si son comportement en cas de nouvelle tentative est sûr.

## L'autorisation et les journaux d'audit doivent préserver l'historique de récupération

Un agent doit pouvoir effectuer une lecture de récupération, mais une lecture qui conduit à une nouvelle modification doit toujours respecter la limite d'autorisation habituelle. Ne dissimulez pas un deuxième déploiement dans une commande appelée `status`.

Sallyport peut conserver l'identifiant SSH hors de l'agent et enregistrer à la fois l'exécution de l'agent et chaque action, ce qui aide à établir qui a autorisé une tentative de récupération et quelle commande a été envoyée. Sa piste d'audit ne remplace pas le reçu côté cible : l'enregistrement d'audit peut prouver qu'une demande d'action a eu lieu, tandis que le reçu et la postcondition expliquent l'état obtenu sur la cible.

Conservez ces éléments séparément dans vos notes d'incident. L'identité de session indique quel processus d'agent détenait l'autorisation. L'enregistrement d'action indique quelle commande a été exécutée sur quel hôte et ce qu'elle a renvoyé. Le reçu cible indique où l'opération s'est arrêtée. Lorsqu'un déploiement échoue, réunir ces faits dans une seule transcription de chat fait perdre les preuves nécessaires.

Demandez une confirmation pour chaque appel concernant une opération irréversible, surtout lorsque la réconciliation peut conduire à la suppression d'identifiants, à une réparation de schéma ou à un nettoyage. Cette approbation supplémentaire est utile lorsque les preuves de l'agent se contredisent : par exemple, le reçu indique qu'un changement est terminé, mais le service actif signale encore l'ancienne version. C'est un point de décision, pas une nouvelle tentative ordinaire.

Une bonne conception de récupération rend l'action prudente facile. Donnez à l'agent une commande de réconciliation en lecture seule, un identifiant d'opération durable et un résultat d'escalade défini. Ainsi, une déconnexion produit un enregistrement vérifiable au lieu d'une seconde tentative qui modifie encore le système.

## Commencez par corriger la commande que l'équipe réexécute déjà

Trouvez la commande SSH que votre équipe relance après un délai d'attente ou un message de déploiement en erreur. Ajoutez un identifiant d'opération, un reçu protégé et une vérification de postcondition en lecture seule avant de modifier quoi que ce soit d'autre.

Puis interrompez-la volontairement. Si le chemin de récupération ne peut pas déterminer si la première tentative a changé l'état, il n'est pas prêt pour un agent autonome. Réécrivez l'opération jusqu'à ce que la réponse provienne de l'hôte cible, et non de la confiance accordée à un code de sortie.
