# 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-<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.

```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/<session>-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 <UUID> --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<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`

```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
