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.
26 KiB
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. InterfaceClientmockable pour les tests, implémentationHTTPClientavec polling 2s + timeout- propagation
context.Context.
- propagation
internal/router— Décide ProviderClaudeCode (Phase 1) vs ProviderGPU / Gemini / ClaudeAPI / Auto (delegation secutools) à partir des nouveaux champs frontmatterpreferred_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 markerinbox/<id>.md.delegated(rebuild-on-restart), et fait tourner un reaper périodique qui finalise les jobs versdone/(succès) oufailed/(échec). Compteurs atomiques (Active / CompletedTotal / FailedTotal) exposés via Snapshot().- Dispatcher: méthode
WithDelegation()opt-in, branchement router avantfindFreeSession(), fallback automatique vers Claude Code si le submit secutools échoue. Skip des*.md.delegateddansassignNextTask. - Frontmatter:
TaskFrontmatterétendu avecPreferredAI,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/statusqui inclut les compteurs delegation.WithDelegation()opt-in — endpoints renvoient 404 si désactivés. - main.go : Manager initialisé seulement si
SECUTOOLS_API_KEYest 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 ./...: okgo vet ./...: cleango 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/secutoolsutilisable depuis bash. Sous-commandessubmit,get,result,decide. EnvCCL_SECUTOOLS_API_KEY(preferred) ouSECUTOOLS_API_KEY.CCL_SECUTOOLS_URLouCCL_SECUTOOLS_MOCK_URLpour 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_KEYenv var pour garder un path par défaut "Phase 1 only". - Smoke test prod restera nécessaire (test E2E avec
preferred_ai: gpuréel) — pas effectué dans ce chantier comme demandé (pas de push). - Décision :
DecideProvider(map[string]any)vit danssecutools(adapter CLI, signature du spec) ETrouter.Decide(Task)vit dansrouter(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 consommeTaskFrontmattertypé via yaml.Unmarshal, alors que la CLI parse manuellement pour rester zero-dep.
[0.3.8] - 2026-04-16
Type: Patch — Bug #1 (A3 flip+ensure inconsistency) + Bug #10 (requiredShared contract test)
Corrigé — Bug #1 (CRITIQUE)
AccountSwitcher.executeSwitchne continue plus silencieusement quandsymlinks.EnsureForAccountéchoue après le flip : il roll-back le lien~/.claudevers le home du compte précédent et n'appelle pasSetActiveAccount. É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 :
partialSwapatomique sticky set,ErrPartialSwapretourné, tout futur swap est refusé tant que le daemon n'est pas redémarré par l'opérateur. - Nouvelle méthode publique
AccountSwitcher.IsPartialSwap() boolpour 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 →ActiveAccountreste compte1 →~/.claudepointe sur previousHome →IsPartialSwap= false.TestFlipEnsureAndRollbackFailure: force les deux flips à échouer (homeDir = fichier régulier) →ErrPartialSwapretourné, flag sticky set, swap suivant refusé.
Ajouté — Bug #10
TestRequiredSharedIsCoherent(internal/symlinks/shared_test.go) : valide le contrat de la constante package-levelRequiredSharedjamais exercée auparavant (tous les autres tests utilisent un override scoped ent.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 reposeEnsureForAccount).
- exactement 3 entrées (
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.executeSwitchappelle désormaissymlinks.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 unt.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 →EnsureForAccountrenvoie 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
SetActiveAccountpas 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 (incluantinternal/switcheretinternal/symlinks). - ✅
go test -race ./internal/switcher/...: PASS. - ✅
go vet ./...: clean.
Fichiers modifiés
internal/switcher/account_switcher.gointernal/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 dansinternal/lifecyclequi agrège lesHomede tous les comptes configurés et délègue àsymlinks.ValidateAll. Échoue dur si un compte n'a pas dehomedéfini ou si un lien est absent/divergent.cmd/claude-failover/main.goappelle cette validation avantEnsureAllSessions(): 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 -fsilencieux qui détruirait l'autre compte.
Tests
- ✅
go test ./...: tous les packages PASS (incluantinternal/lifecycleetinternal/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+ValidateAllqui 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.gointernal/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.DedicatedEN PREMIER dansfindFreeSession, donc les tâches des inboxesfilesecure/(conformvault) etSecuScan/(scanyze) étaient routées versccl-1-conformvault/ccl-2-scanyzequand elles étaient idle — alors que ces sessions sont réservées au travail interactif manuel d'Olivier. - Le watcher envoyait ensuite
/exitquand la tâche se terminait, éjectant Olivier de sa session Claude en cours.
Modifié
Dispatcher.findFreeSession: n'itère plusPool.Dedicated. L'auto-dispatch utilise uniquement le pool autonomeStartIndex..StartIndex+Max./exitsur 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/exitsur 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.gointernal/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-conformvaultouccl-2-scanyzese 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 contreconfig.Pool.Dedicated.completeSessiondistingue pool vs dédié :- Pool :
/exitenvoyé (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.
- Pool :
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é surccl-auto-11(tâche secumon) etccl-auto-12(tâche secuops) : le prompt était bien envoyé mais jamais soumis, l'agent restait au prompt vide. - Cause :
SendKeysenvoietmux send-keys -t <sess> <text> Entermais quandtextcontient 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émentationExecClient.SendEnter=tmux send-keys -t <sess> Enter.- Dispatcher : après
SendKeys(msg),time.Sleep(500ms)puisSendEnter()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.SendEnterinternal/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-clidans secuaas-hosting/inbox depuis 18:39). - Cause : le config avait
prefix: "ccl-auto"(sans tiret), donc les loops généraientccl-auto0..9alors quesetup-tmux.shcréeccl-auto-11..20. Le daemon créait en plus 2 sessions fantômesccl-auto0/1au swap.
Ajouté
config.AutonomousConfig.StartIndex(YAMLstart_index, défaut 0). Les loops du daemon (monitor, dispatcher, switcher kill/recreate, lifecycle ensure/reconcile) itèrent désormaisstart..start+Max-1au lieu de0..Max-1.- Permet au pool autonome
ccl-auto-11..20de coexister avec le pool manuelccl-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/1fantômes kill manuellement, poolccl-auto-11..20intact, pool manuelccl-0..9intact - ✅ Daemon redémarré,
config loaded: ... pool min=10 max=10
Fichiers modifiés
internal/config/config.go— champStartIndexinternal/quota/monitor.go— loop avec StartIndexinternal/switcher/account_switcher.go— kill + recreate avec StartIndexinternal/dispatcher/dispatcher.go— findFreeSession avec StartIndexinternal/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 bonCLAUDE_CONFIG_DIRaprès chaque swap. - La couverture de
saveAllSessions()ne captait que les sessions tracked enstate="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 avantkillAll.switcher.relaunchDedicatedSessions(targetHome): après recréation, envoieCLAUDE_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
- ✅
TestDedicatedRelaunchAfterSwapvérifie : capture UUID → write file → relaunch avec la bonne commande →CLAUDE_CONFIG_DIRpointant sur le home du compte cible. - ✅
go test ./...full suite
Fichiers modifiés
internal/switcher/account_switcher.gointernal/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 -3les 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é
quotaPatternsretravaillé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)
- Retiré :
- Veto 5xx :
serverErrorPatterns= ["api_error","overloaded_error","internal server error","api error: 5"]. Si l'un est présent, même si unquotaPatternmatche,isQuotaExhaustedretournefalse. Un 500/503 n'est pas un quota.
Ajouté
hasServerError()helper + tests exhaustifs :api_error_500_veto,overloaded_error_veto,internal_server_error_vetoreal_rate_limit_error_wins(sanity : vrai 429 passe toujours)
Tests effectués
- ✅ 14 sous-tests
TestIsQuotaExhaustedpassent - ✅
go test ./...complet OK - ✅ Service redémarré
Fichiers modifiés
internal/quota/monitor.gointernal/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
extractResetTimene trouve rien (= pas un vrai 429), le monitor marque l'étatsuspectedHitAtet 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 minutesouresets 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.gointernal/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
SwapRequestedtoutes les 30s, créant un ping-pong entre comptes quand du texte de pane (anciens errors Anthropic 500 / TUI banners) matchaitquotaPatternsavecreset="". 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érifiequota.reactivate_cooldownavant 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.executeSwitchappellestate.RecordSwap()aprèsSetActiveAccount.
Tests effectués
- ✅
go build ./cmd/claude-failoverOK - ✅
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.gointernal/quota/monitor.gointernal/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, ActiveAccountconfig.example.yaml: sections complètes pour tous les composantsscripts/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)