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>
109 lines
2.9 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|