- Replace incremental sync with full scan for new repositories - Add initial_scan status to bypass Since parameter filtering - Implement council formation detection for Design Brief issues - Add version display to WHOOSH UI header for debugging - Fix Docker token authentication with trailing newline removal - Add comprehensive council orchestration with Docker Swarm integration - Include BACKBEAT prototype integration for distributed timing - Support council-specific agent roles and deployment strategies - Transition repositories to active status after content discovery Key architectural improvements: - Full scan approach for new project detection vs incremental sync - Council formation triggered by chorus-entrypoint labeled Design Briefs - Proper token handling and authentication for Gitea API calls - Support for both initial discovery and ongoing task monitoring This enables autonomous project kickoff workflows where Design Brief issues automatically trigger formation of specialized agent councils for new projects. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
328 lines
9.4 KiB
Go
328 lines
9.4 KiB
Go
package agents
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/chorus-services/whoosh/internal/p2p"
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
"github.com/rs/zerolog/log"
|
|
)
|
|
|
|
// Registry manages agent registration and synchronization with the database
|
|
type Registry struct {
|
|
db *pgxpool.Pool
|
|
discovery *p2p.Discovery
|
|
stopCh chan struct{}
|
|
ctx context.Context
|
|
cancel context.CancelFunc
|
|
}
|
|
|
|
// NewRegistry creates a new agent registry service
|
|
func NewRegistry(db *pgxpool.Pool, discovery *p2p.Discovery) *Registry {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
|
|
return &Registry{
|
|
db: db,
|
|
discovery: discovery,
|
|
stopCh: make(chan struct{}),
|
|
ctx: ctx,
|
|
cancel: cancel,
|
|
}
|
|
}
|
|
|
|
// Start begins the agent registry synchronization
|
|
func (r *Registry) Start() error {
|
|
log.Info().Msg("🔄 Starting CHORUS agent registry synchronization")
|
|
|
|
// Start periodic synchronization of discovered agents with database
|
|
go r.syncDiscoveredAgents()
|
|
|
|
return nil
|
|
}
|
|
|
|
// Stop shuts down the agent registry
|
|
func (r *Registry) Stop() error {
|
|
log.Info().Msg("🔄 Stopping CHORUS agent registry synchronization")
|
|
|
|
r.cancel()
|
|
close(r.stopCh)
|
|
|
|
return nil
|
|
}
|
|
|
|
// syncDiscoveredAgents periodically syncs P2P discovered agents to database
|
|
func (r *Registry) syncDiscoveredAgents() {
|
|
// Initial sync
|
|
r.performSync()
|
|
|
|
// Then sync every 30 seconds
|
|
ticker := time.NewTicker(30 * time.Second)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-r.ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
r.performSync()
|
|
}
|
|
}
|
|
}
|
|
|
|
// performSync synchronizes discovered agents with the database
|
|
func (r *Registry) performSync() {
|
|
discoveredAgents := r.discovery.GetAgents()
|
|
|
|
log.Debug().
|
|
Int("discovered_count", len(discoveredAgents)).
|
|
Msg("Synchronizing discovered agents with database")
|
|
|
|
for _, agent := range discoveredAgents {
|
|
err := r.upsertAgent(r.ctx, agent)
|
|
if err != nil {
|
|
log.Error().
|
|
Err(err).
|
|
Str("agent_id", agent.ID).
|
|
Msg("Failed to sync agent to database")
|
|
}
|
|
}
|
|
|
|
// Clean up agents that are no longer discovered
|
|
err := r.markOfflineAgents(r.ctx, discoveredAgents)
|
|
if err != nil {
|
|
log.Error().
|
|
Err(err).
|
|
Msg("Failed to mark offline agents")
|
|
}
|
|
}
|
|
|
|
// upsertAgent inserts or updates an agent in the database
|
|
func (r *Registry) upsertAgent(ctx context.Context, agent *p2p.Agent) error {
|
|
// Convert capabilities to JSON
|
|
capabilitiesJSON, err := json.Marshal(agent.Capabilities)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to marshal capabilities: %w", err)
|
|
}
|
|
|
|
// Create performance metrics
|
|
performanceMetrics := map[string]interface{}{
|
|
"tasks_completed": agent.TasksCompleted,
|
|
"current_team": agent.CurrentTeam,
|
|
"model": agent.Model,
|
|
"cluster_id": agent.ClusterID,
|
|
"p2p_addr": agent.P2PAddr,
|
|
}
|
|
metricsJSON, err := json.Marshal(performanceMetrics)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to marshal performance metrics: %w", err)
|
|
}
|
|
|
|
// Map P2P status to database status
|
|
dbStatus := r.mapStatusToDatabase(agent.Status)
|
|
|
|
// Use upsert query to insert or update
|
|
query := `
|
|
INSERT INTO agents (id, name, endpoint_url, capabilities, status, last_seen, performance_metrics, current_tasks, success_rate)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
|
ON CONFLICT (id)
|
|
DO UPDATE SET
|
|
name = EXCLUDED.name,
|
|
endpoint_url = EXCLUDED.endpoint_url,
|
|
capabilities = EXCLUDED.capabilities,
|
|
status = EXCLUDED.status,
|
|
last_seen = EXCLUDED.last_seen,
|
|
performance_metrics = EXCLUDED.performance_metrics,
|
|
current_tasks = EXCLUDED.current_tasks,
|
|
updated_at = NOW()
|
|
RETURNING id
|
|
`
|
|
|
|
// Generate UUID from agent ID for database consistency
|
|
agentUUID, err := r.generateConsistentUUID(agent.ID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to generate UUID: %w", err)
|
|
}
|
|
|
|
var resultID uuid.UUID
|
|
err = r.db.QueryRow(ctx, query,
|
|
agentUUID, // id
|
|
agent.Name, // name
|
|
agent.Endpoint, // endpoint_url
|
|
capabilitiesJSON, // capabilities
|
|
dbStatus, // status
|
|
agent.LastSeen, // last_seen
|
|
metricsJSON, // performance_metrics
|
|
r.getCurrentTaskCount(agent), // current_tasks
|
|
r.calculateSuccessRate(agent), // success_rate
|
|
).Scan(&resultID)
|
|
|
|
if err != nil {
|
|
return fmt.Errorf("failed to upsert agent: %w", err)
|
|
}
|
|
|
|
log.Debug().
|
|
Str("agent_id", agent.ID).
|
|
Str("db_uuid", resultID.String()).
|
|
Str("status", dbStatus).
|
|
Msg("Synced agent to database")
|
|
|
|
return nil
|
|
}
|
|
|
|
// markOfflineAgents marks agents as offline if they're no longer discovered
|
|
func (r *Registry) markOfflineAgents(ctx context.Context, discoveredAgents []*p2p.Agent) error {
|
|
// Build list of currently discovered agent IDs
|
|
discoveredIDs := make([]string, len(discoveredAgents))
|
|
for i, agent := range discoveredAgents {
|
|
discoveredIDs[i] = agent.ID
|
|
}
|
|
|
|
// Convert to UUIDs for database query
|
|
discoveredUUIDs := make([]uuid.UUID, len(discoveredIDs))
|
|
for i, id := range discoveredIDs {
|
|
uuid, err := r.generateConsistentUUID(id)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to generate UUID for %s: %w", id, err)
|
|
}
|
|
discoveredUUIDs[i] = uuid
|
|
}
|
|
|
|
// If no agents discovered, don't mark all as offline (could be temporary network issue)
|
|
if len(discoveredUUIDs) == 0 {
|
|
return nil
|
|
}
|
|
|
|
// Mark agents as offline if they haven't been seen and aren't in discovered list
|
|
query := `
|
|
UPDATE agents
|
|
SET status = 'offline', updated_at = NOW()
|
|
WHERE status != 'offline'
|
|
AND last_seen < NOW() - INTERVAL '2 minutes'
|
|
AND id != ALL($1)
|
|
`
|
|
|
|
result, err := r.db.Exec(ctx, query, discoveredUUIDs)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to mark offline agents: %w", err)
|
|
}
|
|
|
|
rowsAffected := result.RowsAffected()
|
|
if rowsAffected > 0 {
|
|
log.Info().
|
|
Int64("agents_marked_offline", rowsAffected).
|
|
Msg("Marked agents as offline")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// mapStatusToDatabase maps P2P status to database status values
|
|
func (r *Registry) mapStatusToDatabase(p2pStatus string) string {
|
|
switch p2pStatus {
|
|
case "online":
|
|
return "available"
|
|
case "idle":
|
|
return "idle"
|
|
case "working":
|
|
return "busy"
|
|
default:
|
|
return "available"
|
|
}
|
|
}
|
|
|
|
// getCurrentTaskCount estimates current task count based on status
|
|
func (r *Registry) getCurrentTaskCount(agent *p2p.Agent) int {
|
|
switch agent.Status {
|
|
case "working":
|
|
return 1
|
|
case "idle", "online":
|
|
return 0
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
// calculateSuccessRate calculates success rate based on tasks completed
|
|
func (r *Registry) calculateSuccessRate(agent *p2p.Agent) float64 {
|
|
// For MVP, assume high success rate for all agents
|
|
// In production, this would be calculated from actual task outcomes
|
|
if agent.TasksCompleted > 0 {
|
|
return 0.85 + (float64(agent.TasksCompleted)*0.01) // Success rate increases with experience
|
|
}
|
|
return 0.75 // Default for new agents
|
|
}
|
|
|
|
// generateConsistentUUID generates a consistent UUID from a string ID
|
|
// This ensures the same agent ID always maps to the same UUID
|
|
func (r *Registry) generateConsistentUUID(agentID string) (uuid.UUID, error) {
|
|
// Use UUID v5 (name-based) to generate consistent UUIDs
|
|
// This ensures the same agent ID always produces the same UUID
|
|
namespace := uuid.MustParse("6ba7b810-9dad-11d1-80b4-00c04fd430c8") // DNS namespace UUID
|
|
return uuid.NewSHA1(namespace, []byte(agentID)), nil
|
|
}
|
|
|
|
// GetAvailableAgents returns agents that are available for task assignment
|
|
func (r *Registry) GetAvailableAgents(ctx context.Context) ([]*DatabaseAgent, error) {
|
|
query := `
|
|
SELECT id, name, endpoint_url, capabilities, status, last_seen,
|
|
performance_metrics, current_tasks, success_rate, created_at, updated_at
|
|
FROM agents
|
|
WHERE status IN ('available', 'idle')
|
|
AND last_seen > NOW() - INTERVAL '5 minutes'
|
|
ORDER BY success_rate DESC, current_tasks ASC
|
|
`
|
|
|
|
rows, err := r.db.Query(ctx, query)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to query available agents: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var agents []*DatabaseAgent
|
|
|
|
for rows.Next() {
|
|
agent := &DatabaseAgent{}
|
|
var capabilitiesJSON, metricsJSON []byte
|
|
|
|
err := rows.Scan(
|
|
&agent.ID, &agent.Name, &agent.EndpointURL, &capabilitiesJSON,
|
|
&agent.Status, &agent.LastSeen, &metricsJSON,
|
|
&agent.CurrentTasks, &agent.SuccessRate,
|
|
&agent.CreatedAt, &agent.UpdatedAt,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to scan agent row: %w", err)
|
|
}
|
|
|
|
// Parse JSON fields
|
|
if len(capabilitiesJSON) > 0 {
|
|
json.Unmarshal(capabilitiesJSON, &agent.Capabilities)
|
|
}
|
|
if len(metricsJSON) > 0 {
|
|
json.Unmarshal(metricsJSON, &agent.PerformanceMetrics)
|
|
}
|
|
|
|
agents = append(agents, agent)
|
|
}
|
|
|
|
return agents, rows.Err()
|
|
}
|
|
|
|
// DatabaseAgent represents an agent as stored in the database
|
|
type DatabaseAgent struct {
|
|
ID uuid.UUID `json:"id" db:"id"`
|
|
Name string `json:"name" db:"name"`
|
|
EndpointURL string `json:"endpoint_url" db:"endpoint_url"`
|
|
Capabilities map[string]interface{} `json:"capabilities" db:"capabilities"`
|
|
Status string `json:"status" db:"status"`
|
|
LastSeen time.Time `json:"last_seen" db:"last_seen"`
|
|
PerformanceMetrics map[string]interface{} `json:"performance_metrics" db:"performance_metrics"`
|
|
CurrentTasks int `json:"current_tasks" db:"current_tasks"`
|
|
SuccessRate float64 `json:"success_rate" db:"success_rate"`
|
|
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
|
} |