claude-failover/VERSION.md

542 lines
26 KiB
Markdown
Raw Normal View History

feat(phase2-E): multi-provider routing via secutools delegation Adds optional delegation of agent-queue tasks to the SecuAAS secutools AI platform (GPU / Gemini / Claude API) instead of dispatching to a local Claude Code tmux session. Per-task opt-in via YAML frontmatter fields preferred_ai, allow_delegation, complexity_hint — absence keeps the Phase 1 behaviour exactly (zero breaking change). Go side: - internal/secutools: HTTP client with exponential-backoff retries (SubmitJob/GetJob/WaitForResult), DecideProvider map adapter for CLI use, table tests. - internal/router: struct-typed Decide() with strict precedence (needs_claude_code > preferred_ai=claude-code > allow_delegation=false > preferred_ai > fail-safe local on unknown). - internal/delegation: Manager submits jobs, writes .md.delegated markers for on-restart recovery, runs a periodic reaper that moves completed jobs into done/ with provider/cost footer and failed jobs into failed/. - internal/dispatcher: WithDelegation() opt-in, routeTask hook before findFreeSession, skips .md.delegated in assignNextTask. - internal/api: /api/delegated/status (active jobs + counters), /watchdog/status extended with delegation counters. - cmd/ccl-delegate: small CLI exposing submit/get/result/decide so the bash dispatcher can call the same contract without duplicating logic. - cmd/claude-failover: delegation wired opt-in via SECUTOOLS_API_KEY. Tests: - 29+ new unit tests across router, secutools, delegation, dispatcher, api packages. go test -race -count=1 clean. - tests/phase2-E-integration.sh: bash end-to-end against a Python stdlib mock HTTP server, exercising the dev-management scripts. Forward-compat with watchdog (Phase 1 B1 already ignores state=delegated_to_secutools) so delegated tasks aren't flagged stale.
2026-04-17 02:17:19 +00:00
# Version actuelle : 0.4.0
## [0.4.0] - 2026-04-17
**Type:** Minor — Phase 2 chantier E : multi-provider routing (delegation to secutools)
### Ajouté
- `internal/secutools` — HTTP client (SubmitJob, GetJob, WaitForResult)
pour la plateforme centralisée IA SecuAAS. Interface `Client` mockable
pour les tests, implémentation `HTTPClient` avec polling 2s + timeout
+ propagation `context.Context`.
- `internal/router` — Décide ProviderClaudeCode (Phase 1) vs ProviderGPU /
Gemini / ClaudeAPI / Auto (delegation secutools) à partir des nouveaux
champs frontmatter `preferred_ai`, `allow_delegation`, `complexity_hint`.
Précédence stricte : `needs_claude_code` > `preferred_ai=claude-code` >
`allow_delegation=false` (default) > `preferred_ai=...` > fail-safe Claude
Code sur valeur inconnue.
- `internal/delegation` — Manager qui submit les tâches non-Claude vers
secutools, écrit un marker `inbox/<id>.md.delegated` (rebuild-on-restart),
et fait tourner un reaper périodique qui finalise les jobs vers `done/`
(succès) ou `failed/` (échec). Compteurs atomiques (Active /
CompletedTotal / FailedTotal) exposés via Snapshot().
- Dispatcher: méthode `WithDelegation()` opt-in, branchement router avant
`findFreeSession()`, fallback automatique vers Claude Code si le submit
secutools échoue. Skip des `*.md.delegated` dans `assignNextTask`.
- Frontmatter: `TaskFrontmatter` étendu avec `PreferredAI`, `AllowDelegation`,
`ComplexityHint`. Tous optionnels — un .md sans ces champs garde
exactement le comportement Phase 1.
- API HTTP : nouveau `GET /api/delegated/status` (jobs en cours +
compteurs), `GET /watchdog/status` qui inclut les compteurs delegation.
`WithDelegation()` opt-in — endpoints renvoient 404 si désactivés.
- main.go : Manager initialisé seulement si `SECUTOOLS_API_KEY` est set
(zéro changement pour les déploiements existants). `LoadFromDisk()`
réhydrate les markers en attente après un restart.
### Tests ajoutés (29 nouveaux cas)
- `internal/router` (8 tests) : matrice de décision complète, contrats
IsDelegated, fail-safe sur provider inconnu.
- `internal/secutools` (5 tests) : httptest.Server qui mock le contrat,
HappyPath, HTTPError, polling jusqu'à completed, ErrJobFailed, cancel
via context.
- `internal/delegation` (7 tests) : Submit + marker, rejet provider non
délégué, reap success/fail, LoadFromDisk, Active(), end-to-end.
- `internal/api` (4 tests) : /health, /api/delegated/status désactivé/
activé, /watchdog/status inclut bien les counters.
- `internal/dispatcher` (5 nouveaux tests + 8 existants intacts) :
routeTask parse les nouveaux champs, dispatchProject délègue le GPU,
dispatchProject garde Claude Code en backward-compat, needs_claude_code
bypass, end-to-end inbox→submit→reap→done/.
### Tests effectués
- `go build ./...` : ok
- `go vet ./...` : clean
- `go test -race ./...` : tous les packages passent (~10s)
- `go mod tidy` : aucun changement nécessaire
### Ajouté — complément 2026-04-17 (bash wiring)
- `cmd/ccl-delegate` — CLI Go tiny wrapper autour d'`internal/secutools`
utilisable depuis bash. Sous-commandes `submit`, `get`, `result`,
`decide`. Env `CCL_SECUTOOLS_API_KEY` (preferred) ou `SECUTOOLS_API_KEY`.
`CCL_SECUTOOLS_URL` ou `CCL_SECUTOOLS_MOCK_URL` pour tests.
- `internal/secutools/routing.go``DecideProvider(map[string]any) string`
(adapter signature requis par le spec pour usage depuis CLI). Map-input
permissif (bool|string|int coerced). Fail-safe retourne `"local"`.
Table-driven tests couvrent 14 cas incluant coercions.
- `internal/secutools/client.go` — retries exponential backoff (max 3
retries, 500ms base, x2 chaque attempt). Retry sur 5xx + transport
errors, PAS sur 4xx. `SetRetryPolicy(maxRetries, baseDelay)` exposé
pour tests. Tests : `TestSubmitJob_RetriesOn5xx`,
`TestSubmitJob_DoesNotRetry4xx`.
- `tests/phase2-E-integration.sh` — test bout-en-bout bash côté
dev-management avec mock HTTP secutools (Python stdlib).
Scénarios : decide, delegate (marker + status.json +
state=delegated_to_secutools), drive mock → completed, poll-reaper,
assertions done/ body + footer, cleanup inbox, budget tracker,
rejet tâche legacy (rc=2).
### Notes / décisions
- Pas d'appel réseau réel à secutools dans les tests — fakeClient +
httptest côté Go, serveur mock Python stdlib côté integration.
- Le client secutools est branché sur `SECUTOOLS_API_KEY` env var pour
garder un path par défaut "Phase 1 only".
- Smoke test prod restera nécessaire (test E2E avec `preferred_ai: gpu`
réel) — pas effectué dans ce chantier comme demandé (pas de push).
- Décision : `DecideProvider(map[string]any)` vit dans `secutools`
(adapter CLI, signature du spec) ET `router.Decide(Task)` vit dans
`router` (struct-typed, utilisé par le dispatcher Go). Les deux partagent
la même matrice de précédence — pas de duplication de logique car le
router consomme `TaskFrontmatter` typé via yaml.Unmarshal, alors que
la CLI parse manuellement pour rester zero-dep.
fix(switcher+symlinks): rollback on ensure failure (Bug #1) + requiredShared contract test (Bug #10) Bug #1 (CRITIQUE) — A3 flip+ensure inconsistency - Before: EnsureForAccount failure after flip was WARN-only, SetActiveAccount still fired → daemon declared target active while shared symlinks were absent/divergent → transcripts silently duplicated, resume broken. - After: ensure failure triggers rollback flip to previous account home; if rollback succeeds → explicit error, ActiveAccount stays on previous. If rollback ALSO fails → sticky partialSwap flag + ErrPartialSwap; all further swaps refused until operator intervention (daemon restart). - New public IsPartialSwap() for watchdog / health-check integration. Bug #10 (MOYENNE) — requiredShared contract never exercised - All existing tests override a.sharedSymlinks with tmpdir-scoped lists, so symlinks.RequiredShared itself was never tested. A rename or drop would pass every test but silently break prod failover. - TestRequiredSharedIsCoherent asserts (no filesystem): 3 entries with the exact required names, absolute targets, and a single shared parent directory (invariant EnsureForAccount depends on). Tests: - go test ./... PASS - go test -race ./... PASS (no data race) - 2 new switcher tests: TestFlipEnsureFailureTriggersRollback, TestFlipEnsureAndRollbackFailure - 1 new symlinks test: TestRequiredSharedIsCoherent - 1 obsolete test replaced: TestFlipEnsureSymlinksFailureDoesNotAbortSwap (encoded the old buggy best-effort behaviour)
2026-04-16 19:53:48 +00:00
## [0.3.8] - 2026-04-16
**Type:** Patch — Bug #1 (A3 flip+ensure inconsistency) + Bug #10 (requiredShared contract test)
### Corrigé — Bug #1 (CRITIQUE)
- `AccountSwitcher.executeSwitch` ne continue plus silencieusement quand
`symlinks.EnsureForAccount` échoue après le flip : il **roll-back** le lien
`~/.claude` vers le home du compte précédent et **n'appelle pas**
`SetActiveAccount`. Évite l'état incohérent où le daemon déclare le compte
cible actif alors que ses shared symlinks sont divergents → transcripts
dupliqués silencieusement, resume cassé.
- Si le rollback réussit : swap annulé, état filesystem = état pré-swap,
erreur explicite retournée par `executeSwitchE`.
- Si ensure ET rollback échouent : `partialSwap` atomique sticky set,
`ErrPartialSwap` retourné, tout futur swap est refusé tant que le
daemon n'est pas redémarré par l'opérateur.
- Nouvelle méthode publique `AccountSwitcher.IsPartialSwap() bool` pour
que health-checks et watchdog exposent l'état dégradé.
### Ajouté — Tests Bug #1
- `TestFlipEnsureFailureTriggersRollback` : plant un lien divergent sur
le home cible → ensure échoue → rollback réussit → `ActiveAccount` reste
compte1 → `~/.claude` pointe sur previousHome → `IsPartialSwap` = false.
- `TestFlipEnsureAndRollbackFailure` : force les deux flips à échouer
(homeDir = fichier régulier) → `ErrPartialSwap` retourné, flag sticky
set, swap suivant refusé.
### Ajouté — Bug #10
- `TestRequiredSharedIsCoherent` (`internal/symlinks/shared_test.go`) :
valide le contrat de la constante package-level `RequiredShared` jamais
exercée auparavant (tous les autres tests utilisent un override scoped
en `t.TempDir()`). Vérifie sans toucher au filesystem :
- exactement 3 entrées (`session-env`, `file-history`, `projects`)
- targets absolus
- `filepath.Dir(target)` identique pour les 3 entrées (invariant
"3 liens sous un même shared root" sur lequel repose `EnsureForAccount`).
### Rationale
- Continuer après un ensure échoué revient à valider que le compte cible
est "sain" alors que les shared symlinks sont absents ou divergents.
Conséquence en prod : premier `claude --resume` écrit dans
`~/.claude/projects/` (privé) → transcripts dupliqués, undo
désynchronisé, failover complètement cassé sans log d'alerte.
- Le rollback garantit qu'un compte cible mal configuré ne peut PAS
dégrader le state du daemon : on retourne à l'état pré-swap et on
signale l'erreur à l'appelant.
- `ErrPartialSwap` + `IsPartialSwap()` documente un état où l'intervention
humaine est obligatoire — préférable à un retry automatique qui
empirerait la divergence.
### Tests
-`go test ./...` : tous les packages PASS
-`go test -race ./...` : PASS, aucun data race
-`go vet ./...` : clean
-`go build ./...` : clean
### Fichiers modifiés
- `internal/switcher/account_switcher.go` (+rollback + IsPartialSwap + ErrPartialSwap)
- `internal/switcher/account_switcher_test.go` (2 nouveaux tests, 1 test obsolète remplacé)
- `internal/symlinks/shared_test.go` (+TestRequiredSharedIsCoherent)
## [0.3.7] - 2026-04-16
**Type:** Patch — Phase 1 / Chantier A3 : wire EnsureForAccount post-flip
### Ajouté
- `AccountSwitcher.executeSwitch` appelle désormais
`symlinks.EnsureForAccount(target.Home, ...)` **juste après** le flip
du lien principal `~/.claude`. Garantit que les 3 liens partagés
(`session-env`, `file-history`, `projects`) existent et pointent aux
bons targets sur le compte cible, même si celui-ci vient juste
d'être provisionné.
- `AccountSwitcher.sharedSymlinks` : override test-only (accepte une
liste `[]symlinks.SharedSymlink`). Défaut = `symlinks.RequiredShared`.
Les tests peuvent scoper la réconciliation dans un `t.TempDir()` pour
ne jamais toucher `/home/ubuntu/.claude-*-shared`.
- 2 tests unitaires :
- `TestFlipReconcilesSharedSymlinksOnTargetHome` : target home vide →
les 3 liens sont créés après le flip et pointent aux targets canoniques.
- `TestFlipEnsureSymlinksFailureDoesNotAbortSwap` : lien divergent
planté à la main → `EnsureForAccount` renvoie une erreur, logguée
en WARN, mais le swap complète quand même (best-effort post-flip).
### Rationale
- Sans cet appel, un compte cible fraîchement provisionné n'aurait
pas encore ses 3 liens ; au premier `claude --resume`, Claude Code
écrirait dans `~/.claude/projects/` (privé) au lieu de
`/home/ubuntu/.claude-projects-shared` → transcripts dupliqués,
undo désynchronisé, resume silencieusement cassé.
- L'ensure est **best-effort** : une erreur est logguée en WARN mais
NE bloque PAS le flip. Si on abortait ici, on laisserait le daemon
dans un état incohérent (symlink déjà flippé mais `SetActiveAccount`
pas appelé).
- L'opérateur voit le WARN dans les logs et peut corriger la
divergence manuellement (ex: lien pointant sur le mauvais target).
### Tests
-`go test ./...` : tous les packages PASS (incluant
`internal/switcher` et `internal/symlinks`).
-`go test -race ./internal/switcher/...` : PASS.
-`go vet ./...` : clean.
### Fichiers modifiés
- `internal/switcher/account_switcher.go`
- `internal/switcher/account_switcher_test.go`
## [0.3.6] - 2026-04-16
**Type:** Patch — Phase 1 / Chantier A2 : validation des symlinks au startup
### Ajouté
- `Manager.ValidateSharedSymlinks()` : nouvelle méthode dans
`internal/lifecycle` qui agrège les `Home` de tous les comptes
configurés et délègue à `symlinks.ValidateAll`. Échoue dur si un
compte n'a pas de `home` défini ou si un lien est absent/divergent.
- `cmd/claude-failover/main.go` appelle cette validation **avant**
`EnsureAllSessions()` : un état partagé cassé ne laissera plus le
daemon démarrer et divergér silencieusement.
### Rationale
- Un opérateur qui copie la config sur une nouvelle VM ne peut plus
oublier les liens — le daemon refuse de démarrer jusqu'à ce qu'ils
soient corrects.
- Pas d'auto-heal sur divergence : on préfère un message d'erreur
explicite à un `rm -f` silencieux qui détruirait l'autre compte.
### Tests
-`go test ./...` : tous les packages PASS (incluant
`internal/lifecycle` et `internal/symlinks`).
### Fichiers modifiés
- `cmd/claude-failover/main.go` (+9)
- `internal/lifecycle/manager.go` (+31)
## [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` :
```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)