package dispatcher import ( "context" "errors" "log" "os" "path/filepath" "sync" "testing" "time" "forge.secuaas.ovh/olivier/claude-failover/internal/config" "forge.secuaas.ovh/olivier/claude-failover/internal/delegation" "forge.secuaas.ovh/olivier/claude-failover/internal/router" "forge.secuaas.ovh/olivier/claude-failover/internal/secutools" "forge.secuaas.ovh/olivier/claude-failover/internal/state" ) // fakeSecutools is a minimal stub used for routing tests. It records every // SubmitJob call so tests can assert on the number/contents of submissions. type fakeSecutools struct { mu sync.Mutex submits []*secutools.JobRequest statuses map[string]string results map[string]*secutools.JobResult nextID int } func newFakeSecutools() *fakeSecutools { return &fakeSecutools{ statuses: make(map[string]string), results: make(map[string]*secutools.JobResult), } } func (f *fakeSecutools) SubmitJob(_ context.Context, req *secutools.JobRequest) (*secutools.JobResponse, error) { f.mu.Lock() defer f.mu.Unlock() f.submits = append(f.submits, req) f.nextID++ id := "job-" + itoa(f.nextID) f.statuses[id] = "pending" return &secutools.JobResponse{JobID: id, Status: "pending"}, nil } func (f *fakeSecutools) GetJob(_ context.Context, id string) (*secutools.JobStatus, error) { f.mu.Lock() defer f.mu.Unlock() st, ok := f.statuses[id] if !ok { return nil, errors.New("unknown") } return &secutools.JobStatus{JobID: id, Status: st}, nil } func (f *fakeSecutools) WaitForResult(_ context.Context, id string, _ time.Duration) (*secutools.JobResult, error) { f.mu.Lock() defer f.mu.Unlock() if r, ok := f.results[id]; ok { return r, nil } return nil, errors.New("no result") } // (uses itoa from dispatcher.go) func setupTaskFile(t *testing.T, frontmatter, body string) (projectDir, inbox, taskPath string) { t.Helper() projectDir = t.TempDir() inbox = filepath.Join(projectDir, ".agent-queue", "inbox") if err := os.MkdirAll(inbox, 0755); err != nil { t.Fatal(err) } content := "---\n" + frontmatter + "\n---\n" + body taskPath = filepath.Join(inbox, "task-routing.md") if err := os.WriteFile(taskPath, []byte(content), 0644); err != nil { t.Fatal(err) } return projectDir, inbox, taskPath } // TestRouteTask_ParsesNewFrontmatterFields ensures the dispatcher actually // reads the new preferred_ai/allow_delegation/needs_claude_code fields. func TestRouteTask_ParsesNewFrontmatterFields(t *testing.T) { _, _, taskPath := setupTaskFile(t, "title: Demo\npreferred_ai: gpu\nallow_delegation: true\ncomplexity_hint: low", "Body of task.") d := &Dispatcher{logger: log.Default()} dec, body, err := d.routeTask(taskPath) if err != nil { t.Fatal(err) } if dec.Provider != router.ProviderGPU { t.Errorf("expected ProviderGPU, got %v (%s)", dec.Provider, dec.Reason) } if body != "Body of task." { t.Errorf("body parsed wrong: %q", body) } } // TestDispatchProject_DelegatesGPUTask: a task with allow_delegation=true // and preferred_ai=gpu must be sent to secutools and never reach a tmux // session, even when one is available. func TestDispatchProject_DelegatesGPUTask(t *testing.T) { projectDir, inbox, taskPath := setupTaskFile(t, "title: Delegated\npreferred_ai: gpu\nallow_delegation: true", "Analyze something.") tc := newMockTmux() tc.sessions["pool-0"] = true tc.paneOutput["pool-0"] = "❯ " s := state.New("") s.SetIdle("pool-0") fc := newFakeSecutools() mgr := delegation.New(fc, time.Millisecond) d := &Dispatcher{ tmux: tc, state: s, config: &config.Config{ Pool: config.PoolConfig{ Autonomous: config.AutonomousConfig{Prefix: "pool-", Max: 1}, }, }, logger: log.Default(), delegation: mgr, } d.dispatchProject(inbox) if len(fc.submits) != 1 { t.Fatalf("expected 1 SubmitJob call, got %d", len(fc.submits)) } if fc.submits[0].PreferredAI != "gpu" { t.Errorf("expected preferred_ai=gpu, got %q", fc.submits[0].PreferredAI) } // .delegated marker is present, original .md kept (the reaper finalises later). if _, err := os.Stat(taskPath + ".delegated"); err != nil { t.Errorf("expected .delegated marker, got %v", err) } if _, err := os.Stat(taskPath); err != nil { t.Errorf("expected original .md to remain until reaped, got %v", err) } // pool-0 must remain idle (we did NOT launch a Claude Code agent). if st := s.GetSession("pool-0"); st == nil || st.State != "idle" { t.Errorf("expected pool-0 to stay idle, got %v", st) } _ = projectDir } // TestDispatchProject_LegacyTaskKeepsClaudeCode: backward-compat. A task // with no new fields (or allow_delegation=false implicit) MUST go to a // local Claude Code session. func TestDispatchProject_LegacyTaskKeepsClaudeCode(t *testing.T) { _, inbox, taskPath := setupTaskFile(t, "title: Legacy\npriority: default", "Do classic work.") tc := newMockTmux() tc.sessions["pool-0"] = true tc.paneOutput["pool-0"] = "❯ " s := state.New("") s.SetIdle("pool-0") fc := newFakeSecutools() mgr := delegation.New(fc, time.Millisecond) d := &Dispatcher{ tmux: tc, state: s, config: &config.Config{ Pool: config.PoolConfig{ Autonomous: config.AutonomousConfig{Prefix: "pool-", Max: 1}, }, }, logger: log.Default(), delegation: mgr, } d.dispatchProject(inbox) if len(fc.submits) != 0 { t.Errorf("legacy task must NOT delegate, got %d submits", len(fc.submits)) } // .dispatched marker present, session is working. if _, err := os.Stat(taskPath + ".dispatched"); err != nil { t.Errorf("expected .dispatched marker for Claude Code path, got %v", err) } if st := s.GetSession("pool-0"); st == nil || st.State != "working" { t.Errorf("expected pool-0 working, got %v", st) } } // TestDispatchProject_NeedsClaudeCodeBypassesDelegation: even with // allow_delegation=true, needs_claude_code: true forces local execution. func TestDispatchProject_NeedsClaudeCodeBypassesDelegation(t *testing.T) { _, inbox, _ := setupTaskFile(t, "title: Bypass\npreferred_ai: gpu\nallow_delegation: true\nneeds_claude_code: true", "This needs a real Claude session.") tc := newMockTmux() tc.sessions["pool-0"] = true tc.paneOutput["pool-0"] = "❯ " s := state.New("") s.SetIdle("pool-0") fc := newFakeSecutools() mgr := delegation.New(fc, time.Millisecond) d := &Dispatcher{ tmux: tc, state: s, config: &config.Config{ Pool: config.PoolConfig{ Autonomous: config.AutonomousConfig{Prefix: "pool-", Max: 1}, }, }, logger: log.Default(), delegation: mgr, } d.dispatchProject(inbox) if len(fc.submits) != 0 { t.Errorf("needs_claude_code must skip delegation, got %d submits", len(fc.submits)) } } // TestEndToEnd_DelegationFlow: full happy path from inbox → submit → // reaper → done/, with the dispatcher and delegation manager wired up. func TestEndToEnd_DelegationFlow(t *testing.T) { projectDir, inbox, taskPath := setupTaskFile(t, "title: E2E\npreferred_ai: auto\nallow_delegation: true", "Summarize this.") tc := newMockTmux() s := state.New("") fc := newFakeSecutools() mgr := delegation.New(fc, time.Millisecond) d := &Dispatcher{ tmux: tc, state: s, config: &config.Config{Pool: config.PoolConfig{Autonomous: config.AutonomousConfig{Max: 0}}}, logger: log.Default(), delegation: mgr, } // 1. Dispatch — submits to fakeSecutools. d.dispatchProject(inbox) if len(fc.submits) != 1 { t.Fatalf("expected 1 submit, got %d", len(fc.submits)) } jobID := "job-1" // 2. Backend completes the job. fc.mu.Lock() fc.statuses[jobID] = "completed" fc.results[jobID] = &secutools.JobResult{ JobID: jobID, Response: "Summary", Provider: "gpu", Model: "qwen", CostCAD: 0.001, } fc.mu.Unlock() // 3. Reaper picks it up. mgr.Run drives reapOnce on each ticker tick; // interval was set to 1ms in newDelegationManager so a 200ms // context yields many cycles. mgr.SetLogger(log.Default()) delegationReapNow(t, mgr, jobID) donePath := filepath.Join(projectDir, ".agent-queue", "done", "task-routing.md") body, err := os.ReadFile(donePath) if err != nil { t.Fatalf("missing done/ file: %v", err) } if !contains(string(body), "Summary") || !contains(string(body), "provider: gpu") { t.Errorf("done body missing expected fields:\n%s", body) } // Marker and original .md gone. if _, err := os.Stat(taskPath + ".delegated"); !os.IsNotExist(err) { t.Error("delegated marker should be removed") } if _, err := os.Stat(taskPath); !os.IsNotExist(err) { t.Error("original .md should be removed") } } // delegationReapNow drives one reap cycle of mgr by calling its public // API. We need this because reapOnce is unexported in the delegation // package. The Run loop ticker is too slow for tests, so we expose a // trivial ticker-equivalent: temporarily run with an immediate context. func delegationReapNow(t *testing.T, mgr *delegation.Manager, _ string) { t.Helper() ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) defer cancel() // Run the manager briefly; the ticker fires after `interval` (1ms in // these tests) so within 200ms we get many reap cycles. mgr.Run(ctx) } func contains(s, sub string) bool { if sub == "" { return true } for i := 0; i+len(sub) <= len(s); i++ { if s[i:i+len(sub)] == sub { return true } } return false }