go-agentic/dispatcher.go
Snider 646cc0261b feat(coordination): add agent registry, task router, and dispatcher
Multi-agent coordination layer:
- AgentRegistry interface + MemoryRegistry (heartbeat, reap, discovery)
- TaskRouter interface + DefaultRouter (capability matching, load balancing)
- Dispatcher orchestrates registry + router + allowance for task dispatch

Co-Authored-By: Virgil <virgil@lethean.io>
2026-02-20 07:16:53 +00:00

109 lines
2.9 KiB
Go

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)
}
}
}
}
}