package agentic import ( "context" "time" "forge.lthn.ai/core/go/pkg/log" ) // Dispatcher orchestrates task dispatch by combining the agent registry, // task router, allowance service, and API client. type Dispatcher struct { registry AgentRegistry router TaskRouter allowance *AllowanceService client *Client // can be nil for tests } // NewDispatcher creates a new Dispatcher with the given dependencies. func NewDispatcher(registry AgentRegistry, router TaskRouter, allowance *AllowanceService, client *Client) *Dispatcher { return &Dispatcher{ registry: registry, router: router, allowance: allowance, client: client, } } // Dispatch assigns a task to the best available agent. It queries the registry // for available agents, routes the task, checks the agent's allowance, claims // the task via the API client (if present), and records usage. Returns the // assigned agent ID. func (d *Dispatcher) Dispatch(ctx context.Context, task *Task) (string, error) { const op = "Dispatcher.Dispatch" // 1. Get available agents from registry. agents := d.registry.List() // 2. Route task to best agent. agentID, err := d.router.Route(task, agents) if err != nil { return "", log.E(op, "routing failed", err) } // 3. Check allowance for the selected agent. check, err := d.allowance.Check(agentID, "") if err != nil { return "", log.E(op, "allowance check failed", err) } if !check.Allowed { return "", log.E(op, "agent quota exceeded: "+check.Reason, nil) } // 4. Claim the task via the API client (if available). if d.client != nil { if _, err := d.client.ClaimTask(ctx, task.ID); err != nil { return "", log.E(op, "failed to claim task", err) } } // 5. Record job start usage. if err := d.allowance.RecordUsage(UsageReport{ AgentID: agentID, JobID: task.ID, Event: QuotaEventJobStarted, Timestamp: time.Now().UTC(), }); err != nil { return "", log.E(op, "failed to record usage", err) } return agentID, nil } // DispatchLoop polls for pending tasks at the given interval and dispatches // each one. It runs until the context is cancelled and returns ctx.Err(). func (d *Dispatcher) DispatchLoop(ctx context.Context, interval time.Duration) error { const op = "Dispatcher.DispatchLoop" ticker := time.NewTicker(interval) defer ticker.Stop() for { select { case <-ctx.Done(): return ctx.Err() case <-ticker.C: if d.client == nil { continue } tasks, err := d.client.ListTasks(ctx, ListOptions{Status: StatusPending}) if err != nil { // Log but continue — transient API errors should not stop the loop. _ = log.E(op, "failed to list pending tasks", err) continue } for i := range tasks { if ctx.Err() != nil { return ctx.Err() } if _, err := d.Dispatch(ctx, &tasks[i]); err != nil { // Log dispatch errors but continue with the next task. _ = log.E(op, "failed to dispatch task "+tasks[i].ID, err) } } } } }