diff options
Diffstat (limited to 'internal/executor')
| -rw-r--r-- | internal/executor/executor.go | 150 | ||||
| -rw-r--r-- | internal/executor/executor_test.go | 4 | ||||
| -rw-r--r-- | internal/executor/groq_routing_test.go | 98 | ||||
| -rw-r--r-- | internal/executor/role_dispatch_test.go | 206 |
4 files changed, 453 insertions, 5 deletions
diff --git a/internal/executor/executor.go b/internal/executor/executor.go index 51cf5d9..981a3ad 100644 --- a/internal/executor/executor.go +++ b/internal/executor/executor.go @@ -2,6 +2,7 @@ package executor import ( "context" + "encoding/json" "errors" "fmt" "log/slog" @@ -13,6 +14,7 @@ import ( "github.com/thepeterstone/claudomator/internal/event" "github.com/thepeterstone/claudomator/internal/llm" "github.com/thepeterstone/claudomator/internal/retry" + "github.com/thepeterstone/claudomator/internal/role" "github.com/thepeterstone/claudomator/internal/storage" "github.com/thepeterstone/claudomator/internal/task" "github.com/google/uuid" @@ -38,6 +40,11 @@ type Store interface { GetProject(id string) (*task.Project, error) CreateTask(t *task.Task) error CreateEvent(e *event.Event) error + // GetActiveRoleConfig returns the active role_configs row for a role + // (see internal/role.RoleConfig), or an error (typically sql.ErrNoRows) + // if none is active. Used by execute() to resolve tier 0 of a role-typed + // task's escalation ladder. + GetActiveRoleConfig(role string) (*storage.RoleConfigRow, error) } // LogPather is an optional interface runners can implement to provide the log @@ -77,6 +84,10 @@ type Pool struct { rateLimited map[string]time.Time // agentType -> until cancels map[string]context.CancelFunc // taskID → cancel consecutiveFailures map[string]int // agentType -> count + // roleTierIndex tracks a rotating candidate index per "role:tierIndex" key + // so repeated round_robin tier resolutions (see selectRung) actually + // rotate across candidates rather than always picking the first one. + roleTierIndex map[string]int closed bool // set to true when Shutdown has been called resultCh chan *Result startedCh chan string // task IDs that just transitioned to RUNNING @@ -119,6 +130,7 @@ func NewPool(maxConcurrent int, runners map[string]Runner, store Store, logger * rateLimited: make(map[string]time.Time), cancels: make(map[string]context.CancelFunc), consecutiveFailures: make(map[string]int), + roleTierIndex: make(map[string]int), resultCh: make(chan *Result, maxConcurrent*2), startedCh: make(chan string, maxConcurrent*2), workCh: make(chan workItem, maxConcurrent*10+100), @@ -574,6 +586,85 @@ func (p *Pool) AgentStatuses() []AgentStatusInfo { return out } +// decodeRoleConfig unmarshals a storage.RoleConfigRow's raw ConfigJSON into a +// role.RoleConfig. storage intentionally doesn't depend on internal/role, so +// this decode step lives in each consumer package (executor, scheduler, api). +func decodeRoleConfig(row *storage.RoleConfigRow) (role.RoleConfig, error) { + var rc role.RoleConfig + if row == nil { + return rc, fmt.Errorf("nil role config row") + } + if err := json.Unmarshal([]byte(row.ConfigJSON), &rc); err != nil { + return rc, fmt.Errorf("decoding role config: %w", err) + } + return rc, nil +} + +// findTierIndex returns the index of the first tier in ladder whose +// Candidates contains (provider, model), or -1 if none match. Used to +// determine which tier an already-resolved Agent.Type/Model (set by +// internal/scheduler for a retry/escalation resubmit) belongs to, so the new +// execution's EscalationRung can be stamped correctly without re-resolving. +func findTierIndex(ladder []role.Tier, provider, model string) int { + for i, tier := range ladder { + for _, c := range tier.Candidates { + if c.Provider == provider && c.Model == model { + return i + } + } + } + return -1 +} + +// selectRung picks one candidate rung from tier for roleName's tierIdx-th +// tier. "single" selection mode (or a single-candidate tier) always returns +// Candidates[0]. "round_robin" (the default) rotates through candidates +// across successive calls via p.roleTierIndex, skipping any provider +// currently in p.rateLimited; if every candidate is rate-limited it falls +// back to the one whose rate limit clears soonest (same tie-break spirit as +// pickAgent's rate-limited fallback). +func (p *Pool) selectRung(roleName string, tierIdx int, tier role.Tier) role.Rung { + if len(tier.Candidates) == 0 { + return role.Rung{} + } + if tier.EffectiveSelectionMode() == "single" || len(tier.Candidates) == 1 { + return tier.Candidates[0] + } + + key := fmt.Sprintf("%s:%d", roleName, tierIdx) + n := len(tier.Candidates) + now := time.Now() + + p.mu.Lock() + defer p.mu.Unlock() + if p.roleTierIndex == nil { + p.roleTierIndex = make(map[string]int) + } + start := p.roleTierIndex[key] % n + p.roleTierIndex[key] = (start + 1) % n + + for i := 0; i < n; i++ { + idx := (start + i) % n + cand := tier.Candidates[idx] + if deadline, limited := p.rateLimited[cand.Provider]; !limited || now.After(deadline) { + return cand + } + } + + // All candidates are currently rate-limited: fall back to the one + // clearing soonest. + bestIdx := 0 + var bestDeadline time.Time + for i, cand := range tier.Candidates { + d := p.rateLimited[cand.Provider] + if i == 0 || d.Before(bestDeadline) { + bestDeadline = d + bestIdx = i + } + } + return tier.Candidates[bestIdx] +} + // pickAgent selects the best agent from the given SystemStatus using explicit // load balancing: prefer the available (non-rate-limited) agent with the fewest // active tasks. If all agents are rate-limited, fall back to fewest active. @@ -608,6 +699,50 @@ func pickAgent(status SystemStatus) string { func (p *Pool) execute(ctx context.Context, t *task.Task) { defer p.releaseDispatchSlot() + // 0. Role-based dispatch (additive; every existing task shape has + // Agent.Role == "" and takes none of the branches below). For a + // role-typed task that hasn't yet been assigned a concrete Agent.Type + // (the initial dispatch — Type == ""), resolve tier 0 of the active + // role_configs row's EscalationLadder and apply it to a copy of t, the + // same copy-before-mutate pattern withFailureHistory uses. Escalating to + // later tiers on failure is internal/scheduler's job, not execute()'s — + // by the time a role-typed task reaches execute() a second time with + // Agent.Type already set (scheduler-driven retry/escalation resubmit), + // this block only looks up which tier that (Type, Model) pair belongs to + // (read-only) so the new execution's EscalationRung can be stamped + // correctly; it does not re-resolve or mutate the task. + resolvedRung := -1 + if t.Agent.Role != "" { + if row, err := p.store.GetActiveRoleConfig(t.Agent.Role); err != nil { + p.logger.Warn("no active role config; dispatching without role resolution", "role", t.Agent.Role, "taskID", t.ID, "error", err) + } else if rc, err := decodeRoleConfig(row); err != nil { + p.logger.Error("failed to decode role config", "role", t.Agent.Role, "taskID", t.ID, "error", err) + } else if len(rc.EscalationLadder) > 0 { + if t.Agent.Type == "" { + tier0 := rc.EscalationLadder[0] + selected := p.selectRung(t.Agent.Role, 0, tier0) + if selected.Provider != "" { + nt := *t + nt.Agent = t.Agent + nt.Agent.Type = selected.Provider + nt.Agent.Model = selected.Model + if rc.SystemPrompt != "" { + if nt.Agent.SystemPromptAppend != "" { + nt.Agent.SystemPromptAppend = rc.SystemPrompt + "\n\n" + nt.Agent.SystemPromptAppend + } else { + nt.Agent.SystemPromptAppend = rc.SystemPrompt + } + } + t = &nt + resolvedRung = 0 + p.logger.Info("role dispatch resolved tier 0", "role", t.Agent.Role, "taskID", t.ID, "provider", selected.Provider, "model", selected.Model) + } + } else { + resolvedRung = findTierIndex(rc.EscalationLadder, t.Agent.Type, t.Agent.Model) + } + } + } + // 1. Load-balanced agent selection + model classification. p.mu.Lock() activeTasks := make(map[string]int) @@ -766,12 +901,17 @@ func (p *Pool) execute(ctx context.Context, t *task.Task) { } execID := uuid.New().String() + escalationRung := resolvedRung + if escalationRung < 0 { + escalationRung = 0 + } exec := &storage.Execution{ - ID: execID, - TaskID: t.ID, - StartTime: time.Now().UTC(), - Status: "RUNNING", - Agent: agentType, + ID: execID, + TaskID: t.ID, + StartTime: time.Now().UTC(), + Status: "RUNNING", + Agent: agentType, + EscalationRung: escalationRung, } // Pre-populate log paths so they're available in the DB immediately — diff --git a/internal/executor/executor_test.go b/internal/executor/executor_test.go index cb6554c..8eb7e72 100644 --- a/internal/executor/executor_test.go +++ b/internal/executor/executor_test.go @@ -2,6 +2,7 @@ package executor import ( "context" + "database/sql" "fmt" "log/slog" "os" @@ -1162,6 +1163,9 @@ func (m *minimalMockStore) RecordAgentEvent(_ storage.AgentEvent) error { func (m *minimalMockStore) GetProject(_ string) (*task.Project, error) { return nil, nil } func (m *minimalMockStore) CreateTask(_ *task.Task) error { return nil } func (m *minimalMockStore) CreateEvent(_ *event.Event) error { return nil } +func (m *minimalMockStore) GetActiveRoleConfig(_ string) (*storage.RoleConfigRow, error) { + return nil, sql.ErrNoRows +} func (m *minimalMockStore) lastStateUpdate() (string, task.State, bool) { m.mu.Lock() diff --git a/internal/executor/groq_routing_test.go b/internal/executor/groq_routing_test.go new file mode 100644 index 0000000..722bf95 --- /dev/null +++ b/internal/executor/groq_routing_test.go @@ -0,0 +1,98 @@ +package executor + +import ( + "context" + "encoding/json" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "sync/atomic" + "testing" + + "github.com/thepeterstone/claudomator/internal/llm" + "github.com/thepeterstone/claudomator/internal/provider/openaicompat" +) + +// TestPool_Submit_GroqAgentType_RoutesThroughOpenAICompatProvider is the +// Part A wiring-level confirmation (verification item 5) that Groq — one of +// the three providers that docs/api-keys-setup.md documented as "works once +// configured" but was never actually registered by internal/cli/serve.go or +// run.go before this phase — is a real, usable runner once wired up. Groq is +// OpenAI-wire-compatible, so this reuses the existing Phase 1 +// internal/provider/openaicompat adapter (internal/llm.Client) exactly the +// way internal/cli/cloudrunners.go's registerCloudRunners constructs it: no +// new adapter package, purely config-driven construction pointed at Groq's +// endpoint. +func TestPool_Submit_GroqAgentType_RoutesThroughOpenAICompatProvider(t *testing.T) { + var calls int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&calls, 1) + if r.URL.Path != "/chat/completions" { + t.Errorf("unexpected path %q", r.URL.Path) + } + if got := r.Header.Get("Authorization"); got != "Bearer test-groq-key" { + t.Errorf("wrong Authorization header: %q", got) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "choices": []map[string]any{ + { + "message": map[string]any{"role": "assistant", "content": "## Summary\ndone"}, + "finish_reason": "stop", + }, + }, + "model": "llama-3.3-70b-versatile", + "usage": map[string]any{"prompt_tokens": 10, "completion_tokens": 3}, + }) + })) + defer srv.Close() + + // Mirrors registerCloudRunners' construction for groq/openrouter/openai: + // openaicompat.New(&llm.Client{...}) wrapped in a NativeRunner registered + // under the "groq" runner-map key, SandboxKind "docker" for the real + // wiring (irrelevant here since the task has no ProjectDir/sandbox use). + client := &llm.Client{ + Endpoint: srv.URL, + Model: "llama-3.3-70b-versatile", + APIKey: "test-groq-key", + } + runners := map[string]Runner{ + "groq": &NativeRunner{ + Provider: openaicompat.New(client), + Logger: slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})), + LogDir: t.TempDir(), + }, + } + + store := testStore(t) + logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) + pool := NewPool(2, runners, store, logger) + + tk := makeTask("groq-routing-1") + tk.Agent.Type = "groq" + tk.Agent.Model = "llama-3.3-70b-versatile" + tk.Agent.SkipPlanning = true + if err := store.CreateTask(tk); err != nil { + t.Fatalf("CreateTask: %v", err) + } + + if err := pool.Submit(context.Background(), tk); err != nil { + t.Fatalf("Submit: %v", err) + } + + result := <-pool.Results() + if result.Err != nil { + t.Fatalf("expected no error, got: %v", result.Err) + } + if got := atomic.LoadInt32(&calls); got != 1 { + t.Fatalf("expected exactly 1 call to the fake Groq server, got %d", got) + } + if result.Execution.Agent != "groq" { + t.Errorf("execution.Agent: want %q, got %q", "groq", result.Execution.Agent) + } + if result.Execution.TokensIn != 10 || result.Execution.TokensOut != 3 { + t.Errorf("tokens should come from the openaicompat provider's usage: got in=%d out=%d", + result.Execution.TokensIn, result.Execution.TokensOut) + } +} diff --git a/internal/executor/role_dispatch_test.go b/internal/executor/role_dispatch_test.go new file mode 100644 index 0000000..5628bdb --- /dev/null +++ b/internal/executor/role_dispatch_test.go @@ -0,0 +1,206 @@ +package executor + +import ( + "context" + "encoding/json" + "log/slog" + "os" + "testing" + "time" + + "github.com/thepeterstone/claudomator/internal/role" +) + +// TestPool_Execute_RoleTypedTask_ResolvesTier0 is the Pool.execute()-level +// verification (Phase 5 item 3) that a role-typed task with an empty +// Agent.Type resolves tier 0 of the active role_configs ladder before +// dispatch: it should run through the tier-0 provider/model, and the +// resulting execution should be stamped with EscalationRung 0. +func TestPool_Execute_RoleTypedTask_ResolvesTier0(t *testing.T) { + store := testStore(t) + + rc := role.RoleConfig{ + Role: "coder", + EscalationLadder: []role.Tier{ + { + Candidates: []role.Rung{{Provider: "groq", Model: "llama-3.3-70b-versatile"}}, + SelectionMode: "single", + MaxRetries: 1, + }, + { + Candidates: []role.Rung{{Provider: "anthropic", Model: "claude-sonnet-5"}}, + MaxRetries: 1, + }, + }, + } + configJSON, err := json.Marshal(rc) + if err != nil { + t.Fatalf("marshal role config: %v", err) + } + created, err := store.CreateRoleConfig("coder", string(configJSON), "human") + if err != nil { + t.Fatalf("CreateRoleConfig: %v", err) + } + if err := store.ActivateRoleConfigVersion("coder", created.Version); err != nil { + t.Fatalf("ActivateRoleConfigVersion: %v", err) + } + + runners := map[string]Runner{ + "groq": &mockRunner{}, + } + logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) + pool := NewPool(2, runners, store, logger) + + tk := makeTask("role-task-1") + tk.Agent.Type = "" + tk.Agent.Model = "" + tk.Agent.Role = "coder" + tk.Agent.SkipPlanning = true + if err := store.CreateTask(tk); err != nil { + t.Fatalf("CreateTask: %v", err) + } + + if err := pool.Submit(context.Background(), tk); err != nil { + t.Fatalf("Submit: %v", err) + } + + select { + case result := <-pool.Results(): + if result.Err != nil { + t.Fatalf("expected no error, got: %v", result.Err) + } + if result.Execution.Agent != "groq" { + t.Errorf("execution.Agent: want %q, got %q", "groq", result.Execution.Agent) + } + if result.Execution.EscalationRung != 0 { + t.Errorf("execution.EscalationRung: want 0, got %d", result.Execution.EscalationRung) + } + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for result") + } + + persisted, err := store.GetTask(tk.ID) + if err != nil { + t.Fatalf("GetTask: %v", err) + } + if persisted.Agent.Type != "groq" { + t.Errorf("persisted Agent.Type: want %q, got %q", "groq", persisted.Agent.Type) + } + if persisted.Agent.Model != "llama-3.3-70b-versatile" { + t.Errorf("persisted Agent.Model: want %q, got %q", "llama-3.3-70b-versatile", persisted.Agent.Model) + } +} + +// TestPool_Execute_NonRoleTask_Unaffected proves that a task with +// Agent.Role == "" (every existing YAML/chatbot task shape) takes none of +// the role-resolution branches in execute() — it dispatches exactly as it +// did before this phase, using its explicitly-set Agent.Type/Model verbatim. +func TestPool_Execute_NonRoleTask_Unaffected(t *testing.T) { + store := testStore(t) + runners := map[string]Runner{ + "claude": &mockRunner{}, + } + logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) + pool := NewPool(2, runners, store, logger) + + tk := makeTask("non-role-task-1") + tk.Agent.Type = "claude" + tk.Agent.Model = "sonnet" + // Agent.Role intentionally left "" (zero value). + if err := store.CreateTask(tk); err != nil { + t.Fatalf("CreateTask: %v", err) + } + + if err := pool.Submit(context.Background(), tk); err != nil { + t.Fatalf("Submit: %v", err) + } + + select { + case result := <-pool.Results(): + if result.Err != nil { + t.Fatalf("expected no error, got: %v", result.Err) + } + if result.Execution.Agent != "claude" { + t.Errorf("execution.Agent: want %q, got %q", "claude", result.Execution.Agent) + } + if result.Execution.EscalationRung != 0 { + t.Errorf("non-role task execution.EscalationRung should default to 0, got %d", result.Execution.EscalationRung) + } + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for result") + } + + persisted, err := store.GetTask(tk.ID) + if err != nil { + t.Fatalf("GetTask: %v", err) + } + if persisted.Agent.Type != "claude" || persisted.Agent.Model != "sonnet" { + t.Errorf("non-role task's Agent.Type/Model should be unchanged: got %q/%q", persisted.Agent.Type, persisted.Agent.Model) + } +} + +// TestPool_SelectRung_RoundRobin_SkipsRateLimited proves that repeated +// selectRung calls on a round_robin tier rotate across candidates rather +// than always returning candidate 0, and skip a candidate whose provider is +// currently rate-limited. +func TestPool_SelectRung_RoundRobin_SkipsRateLimited(t *testing.T) { + p := &Pool{ + rateLimited: make(map[string]time.Time), + roleTierIndex: make(map[string]int), + } + tier := role.Tier{ + Candidates: []role.Rung{ + {Provider: "local", Model: "m1"}, + {Provider: "groq", Model: "m2"}, + {Provider: "openrouter", Model: "m3"}, + }, + SelectionMode: "round_robin", + } + + // With nothing rate-limited, repeated calls should rotate through all + // three candidates before repeating. + seen := map[string]int{} + for i := 0; i < 6; i++ { + r := p.selectRung("coder", 0, tier) + seen[r.Provider]++ + } + for _, prov := range []string{"local", "groq", "openrouter"} { + if seen[prov] != 2 { + t.Errorf("expected provider %q to be picked exactly twice across 6 calls, got %d (seen=%v)", prov, seen[prov], seen) + } + } + + // Now rate-limit "groq" and confirm it's skipped on subsequent picks. + p.mu.Lock() + p.rateLimited["groq"] = time.Now().Add(1 * time.Hour) + p.mu.Unlock() + + for i := 0; i < 6; i++ { + r := p.selectRung("coder", 0, tier) + if r.Provider == "groq" { + t.Errorf("expected rate-limited provider %q to be skipped, got picked at iteration %d", "groq", i) + } + } +} + +// TestPool_SelectRung_SingleMode_AlwaysFirstCandidate proves "single" +// selection mode never rotates. +func TestPool_SelectRung_SingleMode_AlwaysFirstCandidate(t *testing.T) { + p := &Pool{ + rateLimited: make(map[string]time.Time), + roleTierIndex: make(map[string]int), + } + tier := role.Tier{ + Candidates: []role.Rung{ + {Provider: "local", Model: "m1"}, + {Provider: "groq", Model: "m2"}, + }, + SelectionMode: "single", + } + for i := 0; i < 4; i++ { + r := p.selectRung("coder", 0, tier) + if r.Provider != "local" { + t.Errorf("iteration %d: expected always \"local\" in single mode, got %q", i, r.Provider) + } + } +} |
