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>
This commit is contained in:
parent
978b60ccf7
commit
c87145ea0b
7 changed files with 989 additions and 8 deletions
30
VERSION.md
Normal file
30
VERSION.md
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
# Version actuelle : 0.2.0
|
||||||
|
|
||||||
|
## [0.2.0] - 2026-04-14
|
||||||
|
**Type:** Minor — Implémentation des goroutines Phase 2
|
||||||
|
|
||||||
|
### Ajouté
|
||||||
|
- Phase 2.1 : `internal/watcher` — SessionWatcher (détection fin de tâche, timeout, signal file)
|
||||||
|
- Phase 2.5 : `internal/notify` — Notifier Telegram + Resend email
|
||||||
|
- Phase 2.2 : `internal/dispatcher` — Dispatcher fsnotify + launchAgent
|
||||||
|
- Phase 2.3 : `internal/quota` — QuotaMonitor (scraping pane tmux)
|
||||||
|
- Phase 2.4 : `internal/switcher` — AccountSwitcher (state machine flip symlink)
|
||||||
|
- Phase 2.6 : `internal/janitor` — Janitor (housekeeping agent-queue)
|
||||||
|
- Phase 2.7 : `cmd/claude-failover/main.go` — Intégration complète toutes goroutines
|
||||||
|
- Nouveaux champs config : `watcher`, `dispatcher`, `janitor`, `notifications`
|
||||||
|
- `state` : ForEachWorking, SetStalled, SetActiveAccount, ActiveAccount
|
||||||
|
- `config.example.yaml` : sections complètes pour tous les composants
|
||||||
|
- `scripts/claude-failover.service` : unité systemd
|
||||||
|
|
||||||
|
### Tests effectués
|
||||||
|
- ✅ go test ./... -race (toutes phases)
|
||||||
|
|
||||||
|
## [0.1.0] - 2026-04-14
|
||||||
|
**Type:** Initial — Daemon skeleton
|
||||||
|
|
||||||
|
### Ajouté
|
||||||
|
- Entry point, signal handling, config YAML loader
|
||||||
|
- tmux.Client interface + ExecClient
|
||||||
|
- State struct (JSON flush, sessions)
|
||||||
|
- HTTP /health + /status
|
||||||
|
- SessionLifecycleManager (reconcile 15s)
|
||||||
528
docs/claude-failover-implementation-complete.md
Normal file
528
docs/claude-failover-implementation-complete.md
Normal file
|
|
@ -0,0 +1,528 @@
|
||||||
|
# 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
|
||||||
2
docs/implementation-plan.pointer.md
Normal file
2
docs/implementation-plan.pointer.md
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
Voir le fichier complet: docs/claude-failover-implementation-complete.md
|
||||||
|
Copié depuis claude.ai le 2026-04-14.
|
||||||
|
|
@ -18,6 +18,10 @@ type Config struct {
|
||||||
Quota QuotaConfig `yaml:"quota"`
|
Quota QuotaConfig `yaml:"quota"`
|
||||||
Checkpoint CheckpointConfig `yaml:"checkpoint"`
|
Checkpoint CheckpointConfig `yaml:"checkpoint"`
|
||||||
MCPHTTP MCPHTTPConfig `yaml:"mcp_http"`
|
MCPHTTP MCPHTTPConfig `yaml:"mcp_http"`
|
||||||
|
Notifications NotificationsConfig `yaml:"notifications"`
|
||||||
|
Dispatcher DispatcherConfig `yaml:"dispatcher"`
|
||||||
|
Watcher WatcherConfig `yaml:"watcher"`
|
||||||
|
Janitor JanitorConfig `yaml:"janitor"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AccountConfig describes a single Anthropic account available to the daemon.
|
// AccountConfig describes a single Anthropic account available to the daemon.
|
||||||
|
|
@ -76,6 +80,35 @@ type MCPHTTPConfig struct {
|
||||||
EnableTrigger bool `yaml:"enable_trigger"`
|
EnableTrigger bool `yaml:"enable_trigger"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NotificationsConfig holds environment variable names for alert credentials.
|
||||||
|
// Actual secrets are read from env at runtime — never stored in config files.
|
||||||
|
type NotificationsConfig struct {
|
||||||
|
TelegramTokenEnv string `yaml:"telegram_token_env"`
|
||||||
|
TelegramChatIDEnv string `yaml:"telegram_chat_id_env"`
|
||||||
|
ResendAPIKeyEnv string `yaml:"resend_api_key_env"`
|
||||||
|
NotifyEmailEnv string `yaml:"notify_email_env"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DispatcherConfig controls the task dispatcher behaviour.
|
||||||
|
type DispatcherConfig struct {
|
||||||
|
ProjectsDir string `yaml:"projects_dir"`
|
||||||
|
IdleTimeout Duration `yaml:"idle_timeout"`
|
||||||
|
PromptTimeout Duration `yaml:"prompt_timeout"`
|
||||||
|
MaxDispatchPerTask int `yaml:"max_dispatch_per_task"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WatcherConfig controls the session watcher behaviour.
|
||||||
|
type WatcherConfig struct {
|
||||||
|
Interval Duration `yaml:"interval"`
|
||||||
|
DoneSignalDir string `yaml:"done_signal_dir"`
|
||||||
|
IdleTimeout Duration `yaml:"idle_timeout"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// JanitorConfig controls the periodic housekeeping goroutine.
|
||||||
|
type JanitorConfig struct {
|
||||||
|
Interval Duration `yaml:"interval"`
|
||||||
|
}
|
||||||
|
|
||||||
// Duration is a time.Duration that unmarshals from YAML strings like "30s", "1h".
|
// Duration is a time.Duration that unmarshals from YAML strings like "30s", "1h".
|
||||||
type Duration struct {
|
type Duration struct {
|
||||||
time.Duration
|
time.Duration
|
||||||
|
|
@ -138,6 +171,27 @@ func (c *Config) defaults() {
|
||||||
if c.Pool.Autonomous.Max == 0 {
|
if c.Pool.Autonomous.Max == 0 {
|
||||||
c.Pool.Autonomous.Max = 10
|
c.Pool.Autonomous.Max = 10
|
||||||
}
|
}
|
||||||
|
if c.Watcher.Interval.Duration == 0 {
|
||||||
|
c.Watcher.Interval.Duration = 30 * time.Second
|
||||||
|
}
|
||||||
|
if c.Watcher.IdleTimeout.Duration == 0 {
|
||||||
|
c.Watcher.IdleTimeout.Duration = 60 * time.Minute
|
||||||
|
}
|
||||||
|
if c.Watcher.DoneSignalDir == "" {
|
||||||
|
c.Watcher.DoneSignalDir = "/tmp"
|
||||||
|
}
|
||||||
|
if c.Janitor.Interval.Duration == 0 {
|
||||||
|
c.Janitor.Interval.Duration = 5 * time.Minute
|
||||||
|
}
|
||||||
|
if c.Dispatcher.IdleTimeout.Duration == 0 {
|
||||||
|
c.Dispatcher.IdleTimeout.Duration = 60 * time.Minute
|
||||||
|
}
|
||||||
|
if c.Dispatcher.PromptTimeout.Duration == 0 {
|
||||||
|
c.Dispatcher.PromptTimeout.Duration = 30 * time.Second
|
||||||
|
}
|
||||||
|
if c.Dispatcher.MaxDispatchPerTask == 0 {
|
||||||
|
c.Dispatcher.MaxDispatchPerTask = 3
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load reads the YAML file at path, expands home paths, and applies defaults.
|
// Load reads the YAML file at path, expands home paths, and applies defaults.
|
||||||
|
|
|
||||||
|
|
@ -133,6 +133,51 @@ func (s *State) SetWorking(name, task string) {
|
||||||
s.touch()
|
s.touch()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetStalled marks the named session as stalled (working but heartbeat too old).
|
||||||
|
func (s *State) SetStalled(name string) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
sess, ok := s.Sessions[name]
|
||||||
|
if !ok {
|
||||||
|
sess = &SessionState{}
|
||||||
|
s.Sessions[name] = sess
|
||||||
|
}
|
||||||
|
sess.State = "stalled"
|
||||||
|
s.touch()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ForEachWorking calls f for each session currently in "working" state.
|
||||||
|
// A snapshot is taken under the read lock; f is called without any lock held.
|
||||||
|
func (s *State) ForEachWorking(f func(name string, sess *SessionState)) {
|
||||||
|
s.mu.RLock()
|
||||||
|
working := make(map[string]SessionState, len(s.Sessions))
|
||||||
|
for name, sess := range s.Sessions {
|
||||||
|
if sess.State == "working" {
|
||||||
|
working[name] = *sess
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.mu.RUnlock()
|
||||||
|
for name, snap := range working {
|
||||||
|
snap := snap
|
||||||
|
f(name, &snap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetActiveAccount updates the active account in the quota state.
|
||||||
|
func (s *State) SetActiveAccount(name string) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.Quota.ActiveAccount = name
|
||||||
|
s.touch()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActiveAccount returns the current active account name.
|
||||||
|
func (s *State) ActiveAccount() string {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
return s.Quota.ActiveAccount
|
||||||
|
}
|
||||||
|
|
||||||
// SetFailed marks the named session as failed and records the failure timestamp.
|
// SetFailed marks the named session as failed and records the failure timestamp.
|
||||||
// The task is preserved for potential requeue by the caller.
|
// The task is preserved for potential requeue by the caller.
|
||||||
func (s *State) SetFailed(name string) {
|
func (s *State) SetFailed(name string) {
|
||||||
|
|
|
||||||
139
internal/watcher/session_watcher.go
Normal file
139
internal/watcher/session_watcher.go
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
// Package watcher detects when a Claude Code session has finished its current
|
||||||
|
// task and signals the dispatcher to assign a new one.
|
||||||
|
package watcher
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"forge.secuaas.ovh/olivier/claude-failover/internal/config"
|
||||||
|
"forge.secuaas.ovh/olivier/claude-failover/internal/state"
|
||||||
|
"forge.secuaas.ovh/olivier/claude-failover/internal/tmux"
|
||||||
|
)
|
||||||
|
|
||||||
|
// spinnerRe matches Claude Code's "Xs ·" or "Xs ⠋" progress indicator.
|
||||||
|
var spinnerRe = regexp.MustCompile(`\d+s\s+[·⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]`)
|
||||||
|
|
||||||
|
// SessionWatcher monitors active tmux sessions and emits on DoneChan when
|
||||||
|
// a Claude Code session returns to the idle prompt (❯) or exceeds its timeout.
|
||||||
|
type SessionWatcher struct {
|
||||||
|
tmux tmux.Client
|
||||||
|
state *state.State
|
||||||
|
config *config.Config
|
||||||
|
done chan string
|
||||||
|
interval time.Duration
|
||||||
|
idleTimeout time.Duration
|
||||||
|
signalDir string
|
||||||
|
logger *log.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a SessionWatcher with defaults from cfg.
|
||||||
|
func New(tc tmux.Client, s *state.State, cfg *config.Config) *SessionWatcher {
|
||||||
|
interval := cfg.Watcher.Interval.Duration
|
||||||
|
if interval == 0 {
|
||||||
|
interval = 30 * time.Second
|
||||||
|
}
|
||||||
|
idleTimeout := cfg.Watcher.IdleTimeout.Duration
|
||||||
|
if idleTimeout == 0 {
|
||||||
|
idleTimeout = 60 * time.Minute
|
||||||
|
}
|
||||||
|
signalDir := cfg.Watcher.DoneSignalDir
|
||||||
|
if signalDir == "" {
|
||||||
|
signalDir = "/tmp"
|
||||||
|
}
|
||||||
|
return &SessionWatcher{
|
||||||
|
tmux: tc,
|
||||||
|
state: s,
|
||||||
|
config: cfg,
|
||||||
|
done: make(chan string, 32),
|
||||||
|
interval: interval,
|
||||||
|
idleTimeout: idleTimeout,
|
||||||
|
signalDir: signalDir,
|
||||||
|
logger: log.Default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DoneChan returns the channel on which completed session names are sent.
|
||||||
|
func (w *SessionWatcher) DoneChan() <-chan string {
|
||||||
|
return w.done
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run starts the watcher loop until ctx is cancelled.
|
||||||
|
func (w *SessionWatcher) Run(ctx context.Context) {
|
||||||
|
ticker := time.NewTicker(w.interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
w.poll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// poll inspects all currently-working sessions once.
|
||||||
|
func (w *SessionWatcher) poll() {
|
||||||
|
w.state.ForEachWorking(func(name string, sess *state.SessionState) {
|
||||||
|
w.checkSession(name, sess)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkSession evaluates a single working session for completion or timeout.
|
||||||
|
func (w *SessionWatcher) checkSession(name string, sess *state.SessionState) {
|
||||||
|
// 1. Check the done-signal file written by hooks or external scripts.
|
||||||
|
sigFile := filepath.Join(w.signalDir, "agent-done-"+name)
|
||||||
|
if _, err := os.Stat(sigFile); err == nil {
|
||||||
|
w.completeSession(name, sigFile)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Capture the last 5 pane lines.
|
||||||
|
tail, err := w.tmux.CapturePaneTail(name, 5)
|
||||||
|
if err != nil {
|
||||||
|
// Session may have vanished; lifecycle.Manager handles recreation.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Idle prompt ❯ without an active spinner → Claude has finished.
|
||||||
|
if hasClaudePrompt(tail) && !hasSpinner(tail) {
|
||||||
|
w.completeSession(name, sigFile)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Idle-timeout guard.
|
||||||
|
if sess.AssignedAt != nil && time.Since(*sess.AssignedAt) > w.idleTimeout {
|
||||||
|
w.logger.Printf("[watcher] TIMEOUT session=%q elapsed=%v idleTimeout=%v",
|
||||||
|
name, time.Since(*sess.AssignedAt).Round(time.Second), w.idleTimeout)
|
||||||
|
w.completeSession(name, sigFile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// completeSession sends /exit, marks the session idle, and notifies the dispatcher.
|
||||||
|
func (w *SessionWatcher) completeSession(name, sigFile string) {
|
||||||
|
w.logger.Printf("[watcher] DONE session=%q → /exit", name)
|
||||||
|
_ = w.tmux.SendKeys(name, "/exit")
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
w.state.SetIdle(name)
|
||||||
|
os.Remove(sigFile)
|
||||||
|
select {
|
||||||
|
case w.done <- name:
|
||||||
|
default:
|
||||||
|
w.logger.Printf("[watcher] done channel full, dropping signal for %q", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasClaudePrompt returns true if the Claude Code interactive prompt is visible.
|
||||||
|
func hasClaudePrompt(output string) bool {
|
||||||
|
return strings.Contains(output, "❯")
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasSpinner returns true if Claude Code's progress spinner is active.
|
||||||
|
func hasSpinner(output string) bool {
|
||||||
|
return spinnerRe.MatchString(output)
|
||||||
|
}
|
||||||
183
internal/watcher/session_watcher_test.go
Normal file
183
internal/watcher/session_watcher_test.go
Normal file
|
|
@ -0,0 +1,183 @@
|
||||||
|
package watcher
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"forge.secuaas.ovh/olivier/claude-failover/internal/state"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mockTmux is a minimal in-memory tmux.Client for tests.
|
||||||
|
type mockTmux struct {
|
||||||
|
sessions map[string]bool
|
||||||
|
paneOutput map[string]string
|
||||||
|
sentKeys []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMockTmux() *mockTmux {
|
||||||
|
return &mockTmux{
|
||||||
|
sessions: make(map[string]bool),
|
||||||
|
paneOutput: make(map[string]string),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockTmux) HasSession(name string) bool { return m.sessions[name] }
|
||||||
|
func (m *mockTmux) CreateSession(name, _ string) error { m.sessions[name] = true; return nil }
|
||||||
|
func (m *mockTmux) KillSession(_ string) error { return nil }
|
||||||
|
func (m *mockTmux) SendKeys(_, keys string) error { m.sentKeys = append(m.sentKeys, keys); return nil }
|
||||||
|
func (m *mockTmux) CapturePaneTail(session string, _ int) (string, error) {
|
||||||
|
return m.paneOutput[session], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestWatcher(tc *mockTmux, s *state.State, signalDir string) *SessionWatcher {
|
||||||
|
return &SessionWatcher{
|
||||||
|
tmux: tc,
|
||||||
|
state: s,
|
||||||
|
done: make(chan string, 32),
|
||||||
|
interval: time.Second,
|
||||||
|
idleTimeout: 60 * time.Minute,
|
||||||
|
signalDir: signalDir,
|
||||||
|
logger: log.Default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSignalFileTriggersDone verifies that agent-done-<session> causes idle + done signal.
|
||||||
|
func TestSignalFileTriggersDone(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
tc := newMockTmux()
|
||||||
|
tc.sessions["sess-a"] = true
|
||||||
|
|
||||||
|
s := state.New("")
|
||||||
|
s.SetWorking("sess-a", "task-1")
|
||||||
|
|
||||||
|
w := newTestWatcher(tc, s, dir)
|
||||||
|
|
||||||
|
sig := filepath.Join(dir, "agent-done-sess-a")
|
||||||
|
if err := os.WriteFile(sig, []byte("done"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.poll()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case got := <-w.done:
|
||||||
|
if got != "sess-a" {
|
||||||
|
t.Errorf("expected sess-a on done, got %q", got)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
t.Fatal("expected done signal, got none")
|
||||||
|
}
|
||||||
|
|
||||||
|
if st := s.GetSession("sess-a"); st == nil || st.State != "idle" {
|
||||||
|
t.Errorf("expected sess-a idle, got %v", st)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(sig); !os.IsNotExist(err) {
|
||||||
|
t.Error("expected signal file to be deleted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPromptDetectionTriggersDone verifies that ❯ in pane output signals completion.
|
||||||
|
func TestPromptDetectionTriggersDone(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
tc := newMockTmux()
|
||||||
|
tc.sessions["sess-b"] = true
|
||||||
|
tc.paneOutput["sess-b"] = "some output\n❯ "
|
||||||
|
|
||||||
|
s := state.New("")
|
||||||
|
s.SetWorking("sess-b", "task-2")
|
||||||
|
|
||||||
|
w := newTestWatcher(tc, s, dir)
|
||||||
|
w.poll()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case got := <-w.done:
|
||||||
|
if got != "sess-b" {
|
||||||
|
t.Errorf("expected sess-b, got %q", got)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
t.Fatal("expected done signal from prompt detection")
|
||||||
|
}
|
||||||
|
|
||||||
|
if st := s.GetSession("sess-b"); st == nil || st.State != "idle" {
|
||||||
|
t.Errorf("expected sess-b idle, got %v", st)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSpinnerSuppressesCompletion verifies that an active spinner prevents false completion.
|
||||||
|
func TestSpinnerSuppressesCompletion(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
tc := newMockTmux()
|
||||||
|
tc.sessions["sess-c"] = true
|
||||||
|
tc.paneOutput["sess-c"] = "doing work 5s · \n❯ "
|
||||||
|
|
||||||
|
s := state.New("")
|
||||||
|
s.SetWorking("sess-c", "task-3")
|
||||||
|
|
||||||
|
w := newTestWatcher(tc, s, dir)
|
||||||
|
w.poll()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case name := <-w.done:
|
||||||
|
t.Errorf("unexpected done signal for %q (spinner should suppress)", name)
|
||||||
|
default:
|
||||||
|
// Correct: no signal while spinner is active.
|
||||||
|
}
|
||||||
|
|
||||||
|
if st := s.GetSession("sess-c"); st == nil || st.State != "working" {
|
||||||
|
t.Errorf("expected sess-c still working, got %v", st)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIdleTimeoutTriggersDone verifies that a session exceeding idleTimeout is completed.
|
||||||
|
func TestIdleTimeoutTriggersDone(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
tc := newMockTmux()
|
||||||
|
tc.sessions["sess-d"] = true
|
||||||
|
tc.paneOutput["sess-d"] = "still running..."
|
||||||
|
|
||||||
|
s := state.New("")
|
||||||
|
s.SetWorking("sess-d", "task-4")
|
||||||
|
|
||||||
|
w := &SessionWatcher{
|
||||||
|
tmux: tc,
|
||||||
|
state: s,
|
||||||
|
done: make(chan string, 32),
|
||||||
|
interval: time.Second,
|
||||||
|
idleTimeout: 1 * time.Millisecond,
|
||||||
|
signalDir: dir,
|
||||||
|
logger: log.Default(),
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
w.poll()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case got := <-w.done:
|
||||||
|
if got != "sess-d" {
|
||||||
|
t.Errorf("expected sess-d, got %q", got)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
t.Fatal("expected done signal from idle timeout")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHasSpinnerPatterns verifies spinner pattern detection.
|
||||||
|
func TestHasSpinnerPatterns(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
input string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"5s · running", true},
|
||||||
|
{"12s ⠋ working", true},
|
||||||
|
{"❯ prompt only", false},
|
||||||
|
{"no spinner here", false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := hasSpinner(c.input); got != c.want {
|
||||||
|
t.Errorf("hasSpinner(%q) = %v, want %v", c.input, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue