claude-failover/internal/watcher/session_watcher.go
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

139 lines
3.9 KiB
Go
Raw 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.

// 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)
}