claude-failover/internal/dispatcher/dispatcher_test.go

243 lines
6.2 KiB
Go
Raw Normal View History

package dispatcher
import (
"log"
"os"
"path/filepath"
"testing"
"forge.secuaas.ovh/olivier/claude-failover/internal/config"
"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) SendEnter(_ string) error {
m.sentKeys = append(m.sentKeys, "<ENTER>")
return nil
}
func (m *mockTmux) CapturePaneTail(session string, _ int) (string, error) {
return m.paneOutput[session], nil
}
// TestParseFrontmatter verifies YAML frontmatter extraction.
func TestParseFrontmatter(t *testing.T) {
input := "---\ntitle: Fix bug\npriority: critical\n---\nDo the fix."
fm, body := parseFrontmatter([]byte(input))
if fm.Title != "Fix bug" {
t.Errorf("expected title 'Fix bug', got %q", fm.Title)
}
if fm.Priority != "critical" {
t.Errorf("expected priority critical, got %q", fm.Priority)
}
if body != "Do the fix." {
t.Errorf("expected body 'Do the fix.', got %q", body)
}
}
// TestParseFrontmatterNoHeader handles files without a YAML header.
func TestParseFrontmatterNoHeader(t *testing.T) {
input := "Just plain content."
fm, body := parseFrontmatter([]byte(input))
if fm.Title != "" {
t.Errorf("expected empty title, got %q", fm.Title)
}
if body != "Just plain content." {
t.Errorf("expected full body, got %q", body)
}
}
// TestModelForPriority maps priority strings to model names.
func TestModelForPriority(t *testing.T) {
cases := []struct{ priority, want string }{
{"critical", "opus"},
{"CRITICAL", "opus"},
{"high", "sonnet"},
{"default", "sonnet"},
{"", "sonnet"},
}
for _, c := range cases {
if got := modelForPriority(c.priority); got != c.want {
t.Errorf("modelForPriority(%q) = %q, want %q", c.priority, got, c.want)
}
}
}
// TestFindFreeSessionSkipsFailed verifies that recently-failed sessions are skipped.
func TestFindFreeSessionSkipsFailed(t *testing.T) {
tc := newMockTmux()
tc.sessions["sess-0"] = true
tc.sessions["sess-1"] = true
s := state.New("")
s.SetFailed("sess-0")
s.SetIdle("sess-1")
d := &Dispatcher{
tmux: tc,
state: s,
config: &config.Config{
Pool: config.PoolConfig{
Autonomous: config.AutonomousConfig{Prefix: "sess-", Max: 2},
},
},
logger: log.Default(),
}
got := d.findFreeSession()
if got != "sess-1" {
t.Errorf("expected sess-1, got %q", got)
}
}
// TestFindFreeSessionMissingTmux skips sessions not in tmux.
func TestFindFreeSessionMissingTmux(t *testing.T) {
tc := newMockTmux()
// sess-0 missing from tmux, sess-1 present and idle.
tc.sessions["sess-1"] = true
s := state.New("")
s.SetIdle("sess-0")
s.SetIdle("sess-1")
d := &Dispatcher{
tmux: tc,
state: s,
config: &config.Config{
Pool: config.PoolConfig{
Autonomous: config.AutonomousConfig{Prefix: "sess-", Max: 2},
},
},
logger: log.Default(),
}
got := d.findFreeSession()
if got != "sess-1" {
t.Errorf("expected sess-1, got %q", got)
}
}
// TestFindFreeSessionSkipsDedicated verifies that dedicated sessions are
// NEVER returned by the auto-dispatch path, even when idle. Those host the
// operator's manual interactive work and must stay untouched.
func TestFindFreeSessionSkipsDedicated(t *testing.T) {
tc := newMockTmux()
tc.sessions["ccl-1-conformvault"] = true
tc.sessions["sess-0"] = true
s := state.New("")
s.SetIdle("ccl-1-conformvault")
s.SetIdle("sess-0")
d := &Dispatcher{
tmux: tc,
state: s,
config: &config.Config{
Pool: config.PoolConfig{
Dedicated: []config.DedicatedSession{{Name: "ccl-1-conformvault"}},
Autonomous: config.AutonomousConfig{Prefix: "sess-", Max: 1},
},
},
logger: log.Default(),
}
got := d.findFreeSession()
if got != "sess-0" {
t.Errorf("expected pool sess-0 (dedicated must be skipped), got %q", got)
}
}
// TestDispatchProject creates a task file, dispatches it, and checks state + rename.
func TestDispatchProject(t *testing.T) {
dir := t.TempDir()
inbox := filepath.Join(dir, ".agent-queue", "inbox")
os.MkdirAll(inbox, 0755)
taskContent := "---\ntitle: My Task\npriority: high\n---\nDo the work."
taskPath := filepath.Join(inbox, "task-001.md")
os.WriteFile(taskPath, []byte(taskContent), 0644)
tc := newMockTmux()
tc.sessions["pool-0"] = true
// Return prompt on first CapturePaneTail call (Claude is ready).
tc.paneOutput["pool-0"] = " "
s := state.New("")
s.SetIdle("pool-0")
d := &Dispatcher{
tmux: tc,
state: s,
config: &config.Config{
Pool: config.PoolConfig{
Autonomous: config.AutonomousConfig{Prefix: "pool-", Max: 1},
},
},
logger: log.Default(),
}
d.dispatchProject(inbox)
if st := s.GetSession("pool-0"); st == nil || st.State != "working" {
t.Errorf("expected session working after dispatch, got %v", st)
}
// Original file renamed to .dispatched.
if _, err := os.Stat(taskPath + ".dispatched"); os.IsNotExist(err) {
t.Error("expected .dispatched marker")
}
if _, err := os.Stat(taskPath); !os.IsNotExist(err) {
t.Error("expected original task file to be renamed")
}
}
// TestDispatchProjectNoFreeSession leaves the task untouched when no session is available.
func TestDispatchProjectNoFreeSession(t *testing.T) {
dir := t.TempDir()
inbox := filepath.Join(dir, ".agent-queue", "inbox")
os.MkdirAll(inbox, 0755)
taskPath := filepath.Join(inbox, "task-002.md")
os.WriteFile(taskPath, []byte("content"), 0644)
tc := newMockTmux() // no sessions
s := state.New("")
d := &Dispatcher{
tmux: tc,
state: s,
config: &config.Config{
Pool: config.PoolConfig{
Autonomous: config.AutonomousConfig{Max: 0},
},
},
logger: log.Default(),
}
d.dispatchProject(inbox)
// File must remain unchanged.
if _, err := os.Stat(taskPath); os.IsNotExist(err) {
t.Error("task file should remain when no session is free")
}
}