claude-failover/docs/claude-failover-implementation-complete.md
Ubuntu c87145ea0b feat(watcher): Phase 2.1 — SessionWatcher goroutine
- internal/watcher: detecte fin de tache via signal file, prompt ❯, idle timeout
- state: ForEachWorking, SetStalled, SetActiveAccount, ActiveAccount
- config: WatcherConfig, DispatcherConfig, JanitorConfig, NotificationsConfig + defaults
- 5 tests unitaires, go test ./... -race OK

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 20:27:51 +00:00

15 KiB
Raw Permalink Blame History

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.

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-<session> 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-<session>
  • 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.

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 :

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 :

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.

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.

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() :

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/<session>-resume-id.txt
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

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<token>/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 <key>
// Body: {"from":"Orchestrator <noreply@secuaas.com>","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

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 :

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 :

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

[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