diff options
| author | Claude Sonnet 5 <noreply@anthropic.com> | 2026-07-03 23:01:50 +0000 |
|---|---|---|
| committer | Claude Sonnet 5 <noreply@anthropic.com> | 2026-07-03 23:01:50 +0000 |
| commit | 787b7fb1aed92c2b701724a7741576053b93cccb (patch) | |
| tree | 372aac85a10093a20ce3152e2c11fcfe20bb3e9d /internal/executor/executor.go | |
| parent | 1f203a7ac0efad15ec3fc0a4c5b335ad7073a52f (diff) | |
feat(role): add versioned role configs + escalation ladder + scheduler (Phase 5)
Two parts:
Part A (fixes a gap from Phase 1): Groq/OpenRouter/OpenAI were documented in
docs/api-keys-setup.md as usable once configured, but nothing actually
constructed runners for them. internal/cli/cloudrunners.go consolidates
anthropic/google/groq/openrouter/openai NativeRunner construction into one
table-driven registerCloudRunners() helper, replacing the two hand-written
per-provider blocks in serve.go/run.go. Groq/OpenRouter/OpenAI reuse
openaicompat (no new adapter code) at SandboxKind: "docker".
Part B: the token-husbanding harness's core routing mechanism.
- internal/role: RoleConfig/Tier/Rung -- a role's system prompt and a
multi-tier (provider, model) escalation ladder, versioned via config_json.
- storage: new role_configs table (draft/active/retired, UNIQUE(role,
version)) with transactional activate-retires-prior-active semantics;
new executions.escalation_rung column.
- task.AgentConfig.Role string -- purely additive; every existing task shape
(Agent.Role == "") is unaffected, proven by TestPool_Execute_NonRoleTask_Unaffected
plus the full pre-existing suite passing unchanged.
- executor.Pool.execute(): role-typed tasks with no Agent.Type yet resolve
tier 0 of their active ladder (round-robin across multi-candidate tiers,
skipping rate-limited providers, falling back to soonest-clearing) before
the existing pickAgent/Classifier path runs; SystemPrompt applies to
Agent.SystemPromptAppend. Already-resolved role tasks (scheduler resubmits)
get their escalation_rung re-derived read-only via findTierIndex.
- internal/scheduler: polls role-typed FAILED tasks, retries at the same
rung under MaxRetries or escalates to the next tier's first candidate when
budget.Accountant.Allow() permits (emitting event.KindEscalated), else
leaves the task FAILED with a final:true KindEscalated event. An
in-memory per-execution-ID "handled" set keeps the poll loop convergent.
Started by `serve` only, config knob [scheduler].poll_interval_seconds.
- internal/api: POST/GET /api/roles/{role}/versions, POST
/api/roles/{role}/activate -- unauthenticated, matching the existing
projects/tasks REST endpoints' auth posture (only chatbot MCP, agent MCP,
and WebSocket are api_token-gated in this codebase today).
Documented as stored-but-not-yet-enforced (CLAUDE.md Design Debt, matching
how task.Priority/RetryConfig are already documented): RoleConfig.Tools/
SandboxKind don't affect dispatch yet; DefaultBudgetUSD is read narrowly as
the scheduler's escalation cost estimate, not enforced at initial dispatch;
scheduler escalation always targets Candidates[0] (no round-robin, unlike
initial-dispatch tier-0 resolution); the scheduler's dedupe is per-process
and resets on restart (idempotent, harmless).
go build/vet/test -race -count=1 all pass, 21 packages.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V1moSNCJRcP6kykA4tyUSs
Diffstat (limited to 'internal/executor/executor.go')
| -rw-r--r-- | internal/executor/executor.go | 150 |
1 files changed, 145 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 — |
