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.
322 lines
14 KiB
Markdown
322 lines
14 KiB
Markdown
# 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` :
|
||
```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)
|