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

528 lines
15 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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