claude-failover/VERSION.md
Ubuntu 91091d7abf feat(symlinks): add shared-state symlink manager (A1)
Adds internal/symlinks package that encodes in code the convention
previously maintained by hand on the VM: every Claude account home
must expose `session-env`, `file-history` and `projects` as symlinks
to a single shared target, so account failover does not create
divergent state (duplicate JSONL transcripts, broken undo history).

- EnsureForAccount(home, required) creates missing links and target
  directories, refuses to auto-correct a divergent link (risks data
  loss), and errors when a regular file sits where the link belongs.
- ValidateAll(homes, required) aggregates errors across both accounts
  so the operator sees every problem at once rather than fixing one
  per restart cycle.
- RequiredShared exposes the production defaults so lifecycle and
  switcher (A2/A3) can depend on it directly.

9/9 unit tests green.

Part of Phase 1 Chantier A — Failover robuste.
2026-04-16 18:55:32 +00:00

14 KiB
Raw Blame History

Version actuelle : 0.3.5

[0.3.5] - 2026-04-16

Type: Patch — Phase 1 / Chantier A1 : package internal/symlinks

Ajouté

  • internal/symlinks/shared.go : EnsureForAccount + ValidateAll qui encodent en code la convention des 3 symlinks partagés par compte (session-env, file-history, projects). Jusqu'à aujourd'hui ces liens étaient maintenus à la main et leur absence silencieuse cassait le failover (JSONL dupliqués, undo désynchronisé).
  • Tests unitaires couvrant : création missing, idempotence, divergence (refus d'auto-correction pour éviter la perte de données), fichier régulier à la place du lien, home vide, agrégation d'erreurs multi-comptes.

Rationale

  • Un déploiement sur une nouvelle VM ne peut plus omettre les liens.
  • Divergent link → erreur explicite, jamais de correction silencieuse.
  • Préparation des tâches A2 (ValidateAll au startup) et A3 (EnsureForAccount post-flipSymlink dans le switcher).

Tests

  • go test ./internal/symlinks/... : 9/9 PASS

Fichiers ajoutés

  • internal/symlinks/shared.go
  • internal/symlinks/shared_test.go

[0.3.4] - 2026-04-16

Type: Patch — Dispatcher ne route JAMAIS vers les sessions dédiées

Corrigé

  • Cause racine (suite au symptôme v0.3.3) : le dispatcher parcourait config.Pool.Dedicated EN PREMIER dans findFreeSession, donc les tâches des inboxes filesecure/ (conformvault) et SecuScan/ (scanyze) étaient routées vers ccl-1-conformvault / ccl-2-scanyze quand elles étaient idle — alors que ces sessions sont réservées au travail interactif manuel d'Olivier.
  • Le watcher envoyait ensuite /exit quand la tâche se terminait, éjectant Olivier de sa session Claude en cours.

Modifié

  • Dispatcher.findFreeSession : n'itère plus Pool.Dedicated. L'auto-dispatch utilise uniquement le pool autonome StartIndex..StartIndex+Max.
  • /exit sur le pool reste le comportement voulu (recycle Claude avec contexte propre pour le prochain dispatch).
  • Le garde v0.3.3 dans watcher.completeSession (pas de /exit sur dédié) reste en place comme défense en profondeur pour tout edge case où un dédié se retrouverait marqué "working".

Tests

  • TestFindFreeSessionSkipsDedicated (nouveau) : vérifie qu'un dédié idle est ignoré au profit du pool.
  • 3 tests existants réécrits pour utiliser le pool Autonomous (ils utilisaient Dedicated comme un mock de pool par paresse).

Fichiers modifiés

  • internal/dispatcher/dispatcher.go
  • internal/dispatcher/dispatcher_test.go

[0.3.3] - 2026-04-16

Type: Patch — Ne pas /exit les sessions dédiées quand leur dispatch finit

Corrigé

  • Bug user-visible : après qu'une tâche dispatchée à ccl-1-conformvault ou ccl-2-scanyze se terminait (prompt sans spinner, ou signal file), le watcher envoyait /exit → Claude s'arrêtait → Olivier se retrouvait au bash prompt en plein milieu de son travail interactif (prompt visible contient littéralement .../exit à la fin).
  • Les sessions dédiées sont une surface partagée : dispatcher peut y poser des tâches, mais l'opérateur y travaille aussi en interactif et ne doit jamais être éjecté.

Ajouté

  • SessionWatcher.isDedicated(name) : test contre config.Pool.Dedicated.
  • completeSession distingue pool vs dédié :
    • Pool : /exit envoyé (recycle Claude avec contexte propre pour le prochain dispatch, comportement inchangé).
    • Dédié : log DONE ... (dedicated — leaving Claude alive), session marquée idle, Claude laissé tourner pour l'opérateur.

Tests

  • go test ./... full suite
  • Déploiement confirmé (ccl-1 relancée, tient depuis)

Fichiers modifiés

  • internal/watcher/session_watcher.go

[0.3.2] - 2026-04-15

Type: Patch — Double-Enter pour soumettre les prompts multi-lignes

Corrigé

  • Les tâches dispatchées restaient coincées dans le buffer d'entrée Claude. Le message s'affichait comme [Pasted text #N +M lines] et Claude attendait indéfiniment un Enter explicite. Constaté sur ccl-auto-11 (tâche secumon) et ccl-auto-12 (tâche secuops) : le prompt était bien envoyé mais jamais soumis, l'agent restait au prompt vide.
  • Cause : SendKeys envoie tmux send-keys -t <sess> <text> Enter mais quand text contient des \n, Claude Code détecte un paste et absorbe le Enter final comme nouvelle ligne du paste. Aucun submit.

Ajouté

  • tmux.Client.SendEnter(session) : envoie un Enter isolé. Implémentation ExecClient.SendEnter = tmux send-keys -t <sess> Enter.
  • Dispatcher : après SendKeys(msg), time.Sleep(500ms) puis SendEnter() pour soumettre le paste.
  • Mocks mis à jour dans 5 fichiers de test (quota, dispatcher, switcher, lifecycle, watcher).

Tests effectués

  • go test ./... full suite (incluant dispatcher)
  • Sessions ccl-auto-11 et ccl-auto-12 débloquées manuellement après Enter, travail en cours depuis

Fichiers modifiés

  • internal/tmux/client.go — interface + ExecClient.SendEnter
  • internal/dispatcher/dispatcher.go — submit après paste
  • 5 fichiers *_test.go — mocks étendus

[0.3.1] - 2026-04-15

Type: Patch — start_index pour faire coexister pool manuel et pool auto

Corrigé

  • Bug prod non détecté depuis longtemps : le pool autonome daemon ignorait le pool réel et vice-versa. Conséquence : aucune tâche automatique n'était dispatchée (ex. installer-d2-cli dans secuaas-hosting/inbox depuis 18:39).
  • Cause : le config avait prefix: "ccl-auto" (sans tiret), donc les loops généraient ccl-auto0..9 alors que setup-tmux.sh crée ccl-auto-11..20. Le daemon créait en plus 2 sessions fantômes ccl-auto0/1 au swap.

Ajouté

  • config.AutonomousConfig.StartIndex (YAML start_index, défaut 0). Les loops du daemon (monitor, dispatcher, switcher kill/recreate, lifecycle ensure/reconcile) itèrent désormais start..start+Max-1 au lieu de 0..Max-1.
  • Permet au pool autonome ccl-auto-11..20 de coexister avec le pool manuel ccl-0..9 (réservé opérateur). Le daemon ne touche que ce qu'il gère.

Modifié

  • /etc/claude-failover/config.yaml :
    autonomous:
      prefix: "ccl-auto-"    # + tiret
      start_index: 11         # NEW
      min: 10
      max: 10
    

Tests effectués

  • go test ./... full suite
  • Sessions ccl-auto0/1 fantômes kill manuellement, pool ccl-auto-11..20 intact, pool manuel ccl-0..9 intact
  • Daemon redémarré, config loaded: ... pool min=10 max=10

Fichiers modifiés

  • internal/config/config.go — champ StartIndex
  • internal/quota/monitor.go — loop avec StartIndex
  • internal/switcher/account_switcher.go — kill + recreate avec StartIndex
  • internal/dispatcher/dispatcher.go — findFreeSession avec StartIndex
  • internal/lifecycle/manager.go — ensure + reconcile avec StartIndex
  • /etc/claude-failover/config.yaml — prefix fixé + start_index

[0.3.0] - 2026-04-15

Type: Minor — Auto-resume des sessions dédiées après un swap légitime

Corrigé

  • Les sessions dédiées (ccl-1-conformvault, ccl-2-scanyze) étaient tuées puis recréées au bash prompt lors d'un swap légitime (vrai 429 quota hit), interrompant le travail interactif en cours. L'opérateur devait relancer manuellement claude --resume <uuid> avec le bon CLAUDE_CONFIG_DIR après chaque swap.
  • La couverture de saveAllSessions() ne captait que les sessions tracked en state="working". Les sessions dédiées user-driven étaient ignorées, donc leur UUID de resume était perdu au kill.

Ajouté

  • switcher.saveDedicatedUUIDs() : capture le UUID de chaque session dédiée configurée, peu importe son tracked state. Appelé juste avant killAll.
  • switcher.relaunchDedicatedSessions(targetHome) : après recréation, envoie CLAUDE_CONFIG_DIR=<targetHome> claude --dangerously-skip-permissions --resume <uuid> dans chaque session dédiée. Si l'UUID manque, la session reste au shell (pas de tentative aveugle).
  • isValidResumeUUID() défense contre un fichier resume-id corrompu (check longueur 36 + regex hex/dash).

Tests

  • TestDedicatedRelaunchAfterSwap vérifie : capture UUID → write file → relaunch avec la bonne commande → CLAUDE_CONFIG_DIR pointant sur le home du compte cible.
  • go test ./... full suite

Fichiers modifiés

  • internal/switcher/account_switcher.go
  • internal/switcher/account_switcher_test.go

[0.2.3] - 2026-04-15

Type: Patch — Veto 5xx pour écarter les faux positifs persistants

Corrigé

  • Les 500/503 d'Anthropic restent visibles dans l'historique de conversation Claude Code (pas juste en flash). Donc tmux capture-pane -S -3 les voyait à chaque poll, et la confirmation 2-polls v0.2.2 finissait par les confirmer → swap sur faux positif persistant.
  • Racine du faux positif : le pattern "rate limit" (substring lâche) matchait dans le contenu textuel d'un 500 rendu par Claude TUI.

Modifié

  • quotaPatterns retravaillés pour privilégier les signatures spécifiques :
    • Retiré : "rate limit" (trop générique, matche les transcripts)
    • Ajouté : "rate_limit_error" (type d'erreur Anthropic pour les vrais 429)
    • Ajouté : "5-hour limit" (phrasing Claude Code)
  • Veto 5xx : serverErrorPatterns = ["api_error", "overloaded_error", "internal server error", "api error: 5"]. Si l'un est présent, même si un quotaPattern matche, isQuotaExhausted retourne false. Un 500/503 n'est pas un quota.

Ajouté

  • hasServerError() helper + tests exhaustifs :
    • api_error_500_veto, overloaded_error_veto, internal_server_error_veto
    • real_rate_limit_error_wins (sanity : vrai 429 passe toujours)

Tests effectués

  • 14 sous-tests TestIsQuotaExhausted passent
  • go test ./... complet OK
  • Service redémarré

Fichiers modifiés

  • internal/quota/monitor.go
  • internal/quota/monitor_test.go

[0.2.2] - 2026-04-15

Type: Patch — Confirmation requise pour les faux positifs (root cause)

Corrigé

  • Cause racine du ping-pong : les erreurs HTTP 500 transitoires d'Anthropic contiennent le texte "rate limit" dans leur payload ({"type":"api_error",...} avec des traces mentionnant "rate limit"). Le monitor les confondait avec de vrais 429 quota hits. La v0.2.1 cassait la boucle via cooldown, mais un swap par salve de 500s pouvait encore tuer les sessions dédiées.
  • Le nouveau log forensique v0.2.1 a révélé exactement ça (snippet capturé : API Error: 500 {"type":"error","error":{"type":"api_error",...}).

Ajouté

  • Confirmation 2-polls pour les hits sans reset time : si extractResetTime ne trouve rien (= pas un vrai 429), le monitor marque l'état suspectedHitAt et attend le poll suivant. Le swap n'est émis que si la détection persiste. Un hit isolé (= erreur 500 transitoire) est absorbé sans swap.
  • Un vrai 429 (avec resets in 45 minutes ou resets at 8pm) continue à déclencher un swap instantané.
  • Monitor.suspectedHitAt (not locked — only touched from poll goroutine).
  • 3 nouveaux tests : TestPollTriggersSwitchOnTwoBlockedPoolWithReset, TestPollRequiresConfirmationWhenNoResetTime, TestPollSuspectedHitClearedOnRecovery.

Tests effectués

  • go test ./... — full suite passe
  • Service redémarré, état consistant

Fichiers modifiés

  • internal/quota/monitor.go
  • internal/quota/monitor_test.go

[0.2.1] - 2026-04-15

Type: Patch — Fix boucle de swaps infinis (ping-pong)

Corrigé

  • Boucle infinie de swaps : le monitor pouvait émettre des SwapRequested toutes les 30s, créant un ping-pong entre comptes quand du texte de pane (anciens errors Anthropic 500 / TUI banners) matchait quotaPatterns avec reset="". En prod, interval observé descendant jusqu'à 1 min.
  • Cause racine : aucun cooldown global entre swaps dans la boucle de détection. La config quota.reactivate_cooldown (5m) existait mais n'était utilisée que par le dispatcher, pas par le monitor.

Ajouté

  • state.QuotaState.LastSwapAt/LastSwapFrom/LastSwapTo + RecordSwap() + LastSwapInfo() pour tracker le dernier swap.
  • monitor.poll() vérifie quota.reactivate_cooldown avant de déclencher un swap. Log explicite quand le cooldown bloque : [quota] swap cooldown active.
  • Log forensique détaillé lors d'un SwapRequested : session déclencheuse, pattern matché, snippet du pane (120 chars). Ex : trigger_session="ccl-1-conformvault" pattern="rate limit" snippet="...".
  • switcher.executeSwitch appelle state.RecordSwap() après SetActiveAccount.

Tests effectués

  • go build ./cmd/claude-failover OK
  • go test ./internal/quota/... ./internal/state/... ./internal/switcher/... OK
  • Binaire installé dans /usr/local/bin/claude-failover
  • Service redémarré — pas de swap intempestif depuis

Fichiers modifiés

  • internal/state/state.go
  • internal/quota/monitor.go
  • internal/switcher/account_switcher.go

[0.2.0] - 2026-04-14

Type: Minor — Implémentation des goroutines Phase 2

Ajouté

  • Phase 2.1 : internal/watcher — SessionWatcher (détection fin de tâche, timeout, signal file)
  • Phase 2.5 : internal/notify — Notifier Telegram + Resend email
  • Phase 2.2 : internal/dispatcher — Dispatcher fsnotify + launchAgent
  • Phase 2.3 : internal/quota — QuotaMonitor (scraping pane tmux)
  • Phase 2.4 : internal/switcher — AccountSwitcher (state machine flip symlink)
  • Phase 2.6 : internal/janitor — Janitor (housekeeping agent-queue)
  • Phase 2.7 : cmd/claude-failover/main.go — Intégration complète toutes goroutines
  • Nouveaux champs config : watcher, dispatcher, janitor, notifications
  • state : ForEachWorking, SetStalled, SetActiveAccount, ActiveAccount
  • config.example.yaml : sections complètes pour tous les composants
  • scripts/claude-failover.service : unité systemd

Tests effectués

  • go test ./... -race (toutes phases)

[0.1.0] - 2026-04-14

Type: Initial — Daemon skeleton

Ajouté

  • Entry point, signal handling, config YAML loader
  • tmux.Client interface + ExecClient
  • State struct (JSON flush, sessions)
  • HTTP /health + /status
  • SessionLifecycleManager (reconcile 15s)