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.
|
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)
|
2026-04-16 19:34:03 +00:00
|
|
|
|
|
|
|
|
|
|
## [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`
|
2026-04-16 19:03:43 +00:00
|
|
|
|
|
|
|
|
|
|
## [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)
|
2026-04-16 18:55:32 +00:00
|
|
|
|
|
|
|
|
|
|
## [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`
|
2026-04-16 13:30:26 +00:00
|
|
|
|
|
|
|
|
|
|
## [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`
|
2026-04-15 20:49:59 +00:00
|
|
|
|
|
|
|
|
|
|
## [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
|
feat(pool): add start_index so manual and auto pools can coexist
Production had two disjoint tmux pools named alike but for different
purposes:
ccl-0..ccl-9 — manual/interactive sessions (operator)
ccl-auto-11..ccl-auto-20 — autonomous dispatcher pool
Until now the daemon's loops iterated prefix + 0..Max, so with the
deployed config ("prefix: ccl-auto", min=2, max=10) the dispatcher
looked for sessions "ccl-auto0..ccl-auto9" that never existed, while
the real auto pool ccl-auto-11..20 was invisible. Net effect: no task
was ever dispatched, and killAllPoolSessions fabricated phantom
"ccl-auto0/1" sessions on each swap.
- AutonomousConfig gains StartIndex (yaml start_index, default 0).
Behaviour is unchanged when StartIndex is 0.
- Monitor, switcher (kill + recreate), dispatcher (findFreeSession),
and lifecycle (EnsureAll + reconcile) all iterate
[StartIndex, StartIndex+Max) so the daemon only touches its own
range and leaves ccl-0..ccl-9 alone.
- Production config updated to prefix: "ccl-auto-", start_index: 11,
min: 10, max: 10 — covering the 10 real ccl-auto-11..20 sessions.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 20:39:57 +00:00
|
|
|
|
|
|
|
|
|
|
## [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
|
2026-04-15 20:24:38 +00:00
|
|
|
|
|
|
|
|
|
|
## [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`
|
2026-04-15 19:26:00 +00:00
|
|
|
|
|
|
|
|
|
|
## [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`
|
2026-04-15 19:18:27 +00:00
|
|
|
|
|
|
|
|
|
|
## [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`
|
2026-04-14 20:27:51 +00:00
|
|
|
|
|
|
|
|
|
|
## [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)
|