# claude-failover — Implémentation complète du daemon Go ## Ce qui existe déjà ``` cmd/claude-failover/main.go ✅ Entry point, signal handling, config load internal/config/config.go ✅ YAML loader, Config struct internal/tmux/client.go ✅ Interface Client + ExecClient (exec.Command) internal/state/state.go ✅ State struct, mutex, JSON flush, GetSession/SetIdle/SetWorking/SetFailed internal/api/server.go ✅ HTTP /health + /status internal/lifecycle/manager.go ✅ SessionLifecycleManager (reconcile 15s, EnsureAllSessions) internal/lifecycle/manager_test.go ✅ 3 tests avec mock tmux ``` ## Ce qui manque — 7 composants à implémenter Chaque composant est une goroutine lancée depuis main.go. Ils communiquent via channels Go. --- ## Phase 2.1 — SessionWatcher Fichier : `internal/watcher/session_watcher.go` Détecte quand une session working a fini (Claude au prompt ❯) ou est stuck. ```go package watcher type SessionWatcher struct { tmux tmux.Client state *state.State config *config.Config done chan string // envoie le nom de session quand une tâche finit interval time.Duration // default 30s } func New(tmux tmux.Client, state *state.State, cfg *config.Config) *SessionWatcher func (w *SessionWatcher) DoneChan() <-chan string func (w *SessionWatcher) Run(ctx context.Context) ``` Logique de `Run()` — toutes les 30s, pour chaque session state=working : 1. Vérifier si le fichier `/tmp/agent-done-` existe → session finie 2. Capturer les 5 dernières lignes du pane tmux 3. Si prompt `❯` visible ET pas de spinner (`[0-9]+s ·`) → Claude a fini 4. Si working depuis > `idle_timeout` (config, default 60min) → timeout, force reset 5. Envoyer le nom de session sur le channel `done` Après détection : - Envoyer `/exit` à la session - `state.SetIdle(session)` - Supprimer `/tmp/agent-done-` - Log l'événement Créer aussi `internal/watcher/session_watcher_test.go` avec mock. --- ## Phase 2.2 — Dispatcher Fichier : `internal/dispatcher/dispatcher.go` C'est le coeur — assigne les tâches aux sessions libres. ```go package dispatcher type Dispatcher struct { tmux tmux.Client state *state.State config *config.Config watcher *fsnotify.Watcher // surveille tous les inbox/ doneChan <-chan string // depuis SessionWatcher ticker *time.Ticker // fallback scan toutes les 60s projectsDir string } func New(tmux tmux.Client, state *state.State, cfg *config.Config, doneChan <-chan string) *Dispatcher func (d *Dispatcher) Run(ctx context.Context) ``` Logique de `Run()` — event loop : ```go func (d *Dispatcher) Run(ctx context.Context) { // Init fsnotify sur tous les .agent-queue/inbox/ d.initWatcher() for { select { case <-ctx.Done(): return case event := <-d.watcher.Events: // Nouveau fichier .md dans un inbox if strings.HasSuffix(event.Name, ".md") && event.Op == fsnotify.Create { d.dispatchProject(filepath.Dir(event.Name)) } case session := <-d.doneChan: // Une session est libre → chercher du travail d.assignNextTask(session) case <-d.ticker.C: // Scan complet fallback d.fullScan() } } } ``` Fonctions internes : ### findFreeSession() string Parcourir les sessions pool. Pour chaque : - `tmux.HasSession()` ? Non → skip (lifecycle les recréera) - `state.GetSession().State == "idle"` ? Non → skip - LastFail < 5min ? → skip (cooldown) - Retourner le nom ### dispatchProject(inboxDir string) 1. Lister les .md dans inbox (exclure .dispatch-meta) 2. Pour chaque tâche non-dispatchée : - Trouver une session libre - Lire la priorité du frontmatter YAML - Déterminer le modèle (opus si critical, sonnet sinon) - Lancer Claude dans la session via `launchAgent()` ### launchAgent(session, projectDir, model, taskFile string) Reproduire la logique de launch-agent.sh en Go : ```go func (d *Dispatcher) launchAgent(session, projectDir, model, taskFile string) error { // 1. cd dans le projet d.tmux.SendKeys(session, "cd "+projectDir) time.Sleep(1 * time.Second) // 2. Construire la commande claude // Le symlink ~/.claude pointe déjà vers le bon compte cmd := fmt.Sprintf("claude --model %s --dangerously-skip-permissions", model) // 3. Chercher un resume UUID resumeFile := filepath.Join(os.Getenv("HOME"), ".claude-context", session+"-resume-id.txt") if data, err := os.ReadFile(resumeFile); err == nil { uuid := strings.TrimSpace(string(data)) if uuid != "" { cmd += " --resume " + uuid os.Remove(resumeFile) } } d.tmux.SendKeys(session, cmd) // 4. Attendre le prompt ❯ (max 30s) if !d.waitForPrompt(session, 30*time.Second) { return fmt.Errorf("claude not ready in %s after 30s", session) } // 5. Lire la tâche et envoyer le message taskContent, _ := os.ReadFile(taskFile) msg := buildTaskMessage(taskContent) d.tmux.SendKeys(session, msg) // 6. Mettre à jour le state d.state.SetWorking(session, filepath.Base(taskFile)) return nil } ``` ### waitForPrompt(session string, timeout time.Duration) bool Poll le pane toutes les 1s. Retourne true si `❯` détecté sans spinner. ### buildTaskMessage(taskContent []byte) string Extraire le body de la tâche (après le frontmatter YAML), construire le message dispatch : ``` Verifie .agent-queue/inbox/ - 1 tache assignee. IMPORTANT: Tu dois EXECUTER les actions... ``` IMPORTANT : une seule tâche par session (pas de batch). Le batch a prouvé être non fiable. Créer `internal/dispatcher/dispatcher_test.go`. --- ## Phase 2.3 — QuotaMonitor Fichier : `internal/quota/monitor.go` Détecte l'épuisement de quota sur les sessions. ```go package quota type Monitor struct { tmux tmux.Client state *state.State config *config.Config switchCh chan SwitchRequest // trigger vers AccountSwitcher interval time.Duration // default 30s } type SwitchRequest struct { From string To string ResetTime string } func New(tmux tmux.Client, state *state.State, cfg *config.Config) *Monitor func (m *Monitor) SwitchChan() <-chan SwitchRequest func (m *Monitor) Run(ctx context.Context) ``` Logique de `Run()` — toutes les 30s : 1. Pour chaque session (pool + interactive) avec Claude actif : - Capturer les 3 dernières lignes du pane tmux (PAS 15 — éviter les faux positifs sur vieux messages) - Chercher les patterns quota : `You've hit your limit`, `rate limit`, `quota exceeded`, `resets [0-9]+[ap]m`, etc. - Compter les sessions bloquées 2. Si >= 2 sessions bloquées (pool) OU >= 1 session bloquée (interactive) : - Extraire le reset time du message - Envoyer un `SwitchRequest` sur le channel 3. AUCUNE dépendance à Claude — tout est du grep sur pane tmux ### extractResetTime(paneContent string) string Parser le texte pour trouver `resets 8pm` ou `in 45 minutes`, convertir en heure. --- ## Phase 2.4 — AccountSwitcher Fichier : `internal/switcher/account_switcher.go` State machine atomique pour le switch de compte. ```go package switcher type AccountSwitcher struct { tmux tmux.Client state *state.State config *config.Config switchCh <-chan quota.SwitchRequest notifier *notify.Notifier currentState SwitchState // normal, saving, switching, resuming, fallback } type SwitchState string const ( StateNormal SwitchState = "normal" StateSaving SwitchState = "saving" StateSwitching SwitchState = "switching" StateResuming SwitchState = "resuming" StateFallback SwitchState = "fallback" ) func New(...) *AccountSwitcher func (a *AccountSwitcher) Run(ctx context.Context) ``` Logique de `Run()` : ```go func (a *AccountSwitcher) Run(ctx context.Context) { for { select { case <-ctx.Done(): return case req := <-a.switchCh: a.executeSwitch(req) } } } func (a *AccountSwitcher) executeSwitch(req SwitchRequest) { // 1. SAVING — capturer le contexte de toutes les sessions a.currentState = StateSaving a.saveAllSessions() // capture pane + resume UUID (--force, pas de prompt Claude) // 2. SWITCHING — flip symlink + kill + recreate a.currentState = StateSwitching targetAccount := a.findTargetAccount(req.From) a.flipSymlink(targetAccount.Home) a.killAllPoolSessions() a.recreatePoolSessions() a.switchInteractiveSessions(targetAccount) // 3. Update state a.state.Quota.Paused = false a.state.Quota.ActiveAccount = targetAccount.Name // 4. RESUMING — les sessions sont prêtes, le dispatcher les remplira a.currentState = StateResuming // 5. Notifier a.notifier.Email("[Orchestrator] Switch compte → "+targetAccount.Name, fmt.Sprintf("Switch %s → %s, reset: %s", req.From, targetAccount.Name, req.ResetTime)) a.notifier.Telegram(fmt.Sprintf("🔄 Switch %s → %s (reset: %s)", req.From, targetAccount.Name, req.ResetTime)) // 6. Programmer le retour go a.scheduleReturn(req.From, req.ResetTime) a.currentState = StateFallback } func (a *AccountSwitcher) scheduleReturn(primaryAccount, resetTime string) { duration := timeUntilReset(resetTime) + 5*time.Minute time.Sleep(duration) a.executeSwitch(quota.SwitchRequest{From: a.state.Quota.ActiveAccount, To: primaryAccount}) a.currentState = StateNormal } ``` ### saveAllSessions() Pour chaque session avec Claude actif : - Capturer le pane complet (-S -200) - Extraire le resume UUID via regex `claude --resume [a-f0-9-]{36}` - Sauvegarder dans `~/.claude-context/-resume-id.txt` ### flipSymlink(targetHome string) ```go home, _ := os.UserHomeDir() os.Remove(filepath.Join(home, ".claude")) os.Symlink(targetHome, filepath.Join(home, ".claude")) ``` ### switchInteractiveSessions() Pour chaque session interactive (config.Pool.Dedicated) : - Capturer resume UUID avant /exit - SendKeys "/exit" - Sleep 2s - SendKeys "claude --resume --model sonnet --dangerously-skip-permissions" --- ## Phase 2.5 — Notifier Fichier : `internal/notify/notifier.go` ```go package notify type Notifier struct { telegramToken string telegramChatID string resendAPIKey string notifyEmail string } func New(cfg *config.Config) *Notifier func (n *Notifier) Telegram(msg string) error // POST https://api.telegram.org/bot/sendMessage // Body: {"chat_id": chatID, "text": msg, "parse_mode": "HTML"} func (n *Notifier) Email(subject, htmlBody string) error // POST https://api.resend.com/emails // Headers: Authorization: Bearer // Body: {"from":"Orchestrator ","to":[email],"subject":subject,"html":htmlBody} ``` Les tokens sont lus depuis les variables d'environnement : - TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID - RESEND_API_KEY - BETAWATCH_NOTIFY_EMAIL (default: olivier@secuaas.com) --- ## Phase 2.6 — Janitor Fichier : `internal/janitor/janitor.go` ```go package janitor type Janitor struct { state *state.State config *config.Config projectsDir string interval time.Duration // default 5min } func New(state *state.State, cfg *config.Config) *Janitor func (j *Janitor) Run(ctx context.Context) ``` Logique toutes les 5 minutes : 1. Scanner les tâches dans `active/` sans session working → requeue dans `inbox/` 2. Nettoyer les `.dispatch-meta` orphelins (session n'existe plus ou idle) 3. Recalculer `status.json` de chaque projet (inbox_count, done_count, failed_count) 4. Supprimer les fichiers `/tmp/agent-done-*` stale (> 1h) --- ## Phase 2.7 — Intégration main.go + State flush Mettre à jour cmd/claude-failover/main.go pour lancer toutes les goroutines : ```go func main() { // ... existing config/state/tmux init ... // Notifier notifier := notify.New(cfg) // SessionWatcher sw := watcher.New(tmuxClient, s, cfg) go sw.Run(ctx) // QuotaMonitor qm := quota.New(tmuxClient, s, cfg) go qm.Run(ctx) // AccountSwitcher as := switcher.New(tmuxClient, s, cfg, qm.SwitchChan(), notifier) go as.Run(ctx) // Dispatcher disp := dispatcher.New(tmuxClient, s, cfg, sw.DoneChan()) go disp.Run(ctx) // Janitor jan := janitor.New(s, cfg) go jan.Run(ctx) // Lifecycle (déjà en place) go lm.Run(ctx) // State flush loop (10s) go func() { ticker := time.NewTicker(10 * time.Second) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: s.Flush() } } }() // HTTP API (déjà en place) go srv.Start() log.Printf("claude-failover v%s started — all goroutines running", version) <-ctx.Done() s.Flush() } ``` --- ## Phase 3 — Config update + Systemd ### Mettre à jour config.example.yaml Ajouter les sections manquantes pour matcher les nouveaux composants : ```yaml notifications: telegram_token_env: "TELEGRAM_BOT_TOKEN" telegram_chat_id_env: "TELEGRAM_CHAT_ID" resend_api_key_env: "RESEND_API_KEY" notify_email_env: "BETAWATCH_NOTIFY_EMAIL" dispatcher: projects_dir: "~/projects" idle_timeout: "60m" prompt_timeout: "5m" max_dispatch_per_task: 3 cooldown_schedule: [0, 300, 900] watcher: interval: "30s" done_signal_dir: "/tmp" janitor: interval: "5m" ``` Mettre à jour le Config struct dans config.go pour matcher. ### Créer scripts/claude-failover.service ```ini [Unit] Description=Claude Failover — Session Orchestrator After=network.target [Service] Type=simple User=ubuntu ExecStartPre=/usr/bin/loginctl enable-linger ubuntu ExecStart=/usr/local/bin/claude-failover --config /etc/claude-failover/config.yaml Restart=always RestartSec=5 KillMode=mixed TimeoutStopSec=60 EnvironmentFile=-/etc/claude-failover/env [Install] WantedBy=multi-user.target ``` --- ## Ordre d'implémentation 1. Phase 2.1 — SessionWatcher + test → commit + push 2. Phase 2.5 — Notifier → commit + push 3. Phase 2.2 — Dispatcher + test → commit + push 4. Phase 2.3 — QuotaMonitor → commit + push 5. Phase 2.4 — AccountSwitcher → commit + push 6. Phase 2.6 — Janitor → commit + push 7. Phase 2.7 — main.go intégration + state flush → commit + push 8. Phase 3 — Config update + systemd → commit + push 9. go test ./... final ## Rappels CRITIQUES - Repo PUBLIC — AUCUN path hardcodé, IP, secret, nom de produit interne - Tous les paths viennent de la config YAML - go.mod module path : `forge.secuaas.ovh/olivier/claude-failover` - 1 TÂCHE PAR SESSION — ne jamais dispatcher en batch - `go test ./...` doit passer à chaque étape - Commit + push après chaque phase fonctionnelle