claude-failover/docs/claude-failover-implementation-complete.md

529 lines
15 KiB
Markdown
Raw Permalink Normal View 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.
```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