Fix council formation broadcast and reduce core roles
Two critical fixes for E2E council workflow: 1. Reduced core council roles from 8 to 2 (tpm + senior-software-architect) - Faster council formation - Easier debugging - Sufficient for initial project planning 2. Added broadcast to monitor path - Monitor now broadcasts council opportunities to CHORUS agents - Previously only webhook path had broadcast, monitor path missed it - Added broadcaster parameter to NewMonitor() - Broadcast sent after council formation with 30s timeout Verified working: - Council formation successful - Broadcast to CHORUS agents confirmed - Role claims received (TPM claimed and loaded) - Persona status "loaded" acknowledged Image: anthonyrawlins/whoosh:broadcast-fix Council: 2dad2070-292a-4dbd-9195-89795f84da19 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -35,14 +35,18 @@ type CouncilComposition struct {
|
|||||||
|
|
||||||
// CouncilAgent represents a single agent in the council
|
// CouncilAgent represents a single agent in the council
|
||||||
type CouncilAgent struct {
|
type CouncilAgent struct {
|
||||||
AgentID string `json:"agent_id"`
|
AgentID string `json:"agent_id"`
|
||||||
RoleName string `json:"role_name"`
|
RoleName string `json:"role_name"`
|
||||||
AgentName string `json:"agent_name"`
|
AgentName string `json:"agent_name"`
|
||||||
Required bool `json:"required"`
|
Required bool `json:"required"`
|
||||||
Deployed bool `json:"deployed"`
|
Deployed bool `json:"deployed"`
|
||||||
ServiceID string `json:"service_id,omitempty"`
|
ServiceID string `json:"service_id,omitempty"`
|
||||||
DeployedAt *time.Time `json:"deployed_at,omitempty"`
|
DeployedAt *time.Time `json:"deployed_at,omitempty"`
|
||||||
Status string `json:"status"` // pending, deploying, active, failed
|
Status string `json:"status"` // pending, assigned, deploying, active, failed
|
||||||
|
PersonaStatus *string `json:"persona_status,omitempty"` // pending, loading, loaded, failed
|
||||||
|
PersonaLoadedAt *time.Time `json:"persona_loaded_at,omitempty"`
|
||||||
|
EndpointURL *string `json:"endpoint_url,omitempty"`
|
||||||
|
PersonaAckPayload map[string]interface{} `json:"persona_ack_payload,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CouncilDeploymentResult represents the result of council agent deployment
|
// CouncilDeploymentResult represents the result of council agent deployment
|
||||||
@@ -81,15 +85,10 @@ type CouncilArtifacts struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CoreCouncilRoles defines the required roles for any project kickoff council
|
// CoreCouncilRoles defines the required roles for any project kickoff council
|
||||||
|
// Reduced to minimal set for faster formation and easier debugging
|
||||||
var CoreCouncilRoles = []string{
|
var CoreCouncilRoles = []string{
|
||||||
"systems-analyst",
|
|
||||||
"senior-software-architect",
|
|
||||||
"tpm",
|
"tpm",
|
||||||
"security-architect",
|
"senior-software-architect",
|
||||||
"devex-platform-engineer",
|
|
||||||
"qa-test-engineer",
|
|
||||||
"sre-observability-lead",
|
|
||||||
"technical-writer",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// OptionalCouncilRoles defines the optional roles that may be included based on project needs
|
// OptionalCouncilRoles defines the optional roles that may be included based on project needs
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
325
internal/p2p/broadcaster.go
Normal file
325
internal/p2p/broadcaster.go
Normal file
@@ -0,0 +1,325 @@
|
|||||||
|
package p2p
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Broadcaster handles P2P broadcasting of opportunities and events to CHORUS agents
|
||||||
|
type Broadcaster struct {
|
||||||
|
discovery *Discovery
|
||||||
|
ctx context.Context
|
||||||
|
cancel context.CancelFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewBroadcaster creates a new P2P broadcaster
|
||||||
|
func NewBroadcaster(discovery *Discovery) *Broadcaster {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
|
||||||
|
return &Broadcaster{
|
||||||
|
discovery: discovery,
|
||||||
|
ctx: ctx,
|
||||||
|
cancel: cancel,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close shuts down the broadcaster
|
||||||
|
func (b *Broadcaster) Close() error {
|
||||||
|
b.cancel()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CouncilOpportunity represents a council formation opportunity for agents to claim
|
||||||
|
type CouncilOpportunity struct {
|
||||||
|
CouncilID uuid.UUID `json:"council_id"`
|
||||||
|
ProjectName string `json:"project_name"`
|
||||||
|
Repository string `json:"repository"`
|
||||||
|
ProjectBrief string `json:"project_brief"`
|
||||||
|
CoreRoles []CouncilRole `json:"core_roles"`
|
||||||
|
OptionalRoles []CouncilRole `json:"optional_roles"`
|
||||||
|
UCXLAddress string `json:"ucxl_address"`
|
||||||
|
FormationDeadline time.Time `json:"formation_deadline"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
Metadata map[string]interface{} `json:"metadata"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CouncilRole represents a role within a council that can be claimed
|
||||||
|
type CouncilRole struct {
|
||||||
|
RoleName string `json:"role_name"`
|
||||||
|
AgentName string `json:"agent_name"`
|
||||||
|
Required bool `json:"required"`
|
||||||
|
RequiredSkills []string `json:"required_skills"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RoleCounts provides claimed vs total counts for a role category
|
||||||
|
type RoleCounts struct {
|
||||||
|
Total int `json:"total"`
|
||||||
|
Claimed int `json:"claimed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PersonaCounts captures persona readiness across council roles.
|
||||||
|
type PersonaCounts struct {
|
||||||
|
Total int `json:"total"`
|
||||||
|
Loaded int `json:"loaded"`
|
||||||
|
CoreLoaded int `json:"core_loaded"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CouncilStatusUpdate notifies agents about council staffing progress
|
||||||
|
type CouncilStatusUpdate struct {
|
||||||
|
CouncilID uuid.UUID `json:"council_id"`
|
||||||
|
ProjectName string `json:"project_name"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
Timestamp time.Time `json:"timestamp"`
|
||||||
|
CoreRoles RoleCounts `json:"core_roles"`
|
||||||
|
Optional RoleCounts `json:"optional_roles"`
|
||||||
|
Personas PersonaCounts `json:"personas,omitempty"`
|
||||||
|
BriefDispatched bool `json:"brief_dispatched"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BroadcastCouncilOpportunity broadcasts a council formation opportunity to all available CHORUS agents
|
||||||
|
func (b *Broadcaster) BroadcastCouncilOpportunity(ctx context.Context, opportunity *CouncilOpportunity) error {
|
||||||
|
log.Info().
|
||||||
|
Str("council_id", opportunity.CouncilID.String()).
|
||||||
|
Str("project_name", opportunity.ProjectName).
|
||||||
|
Int("core_roles", len(opportunity.CoreRoles)).
|
||||||
|
Int("optional_roles", len(opportunity.OptionalRoles)).
|
||||||
|
Msg("📡 Broadcasting council opportunity to CHORUS agents")
|
||||||
|
|
||||||
|
// Get all discovered agents
|
||||||
|
agents := b.discovery.GetAgents()
|
||||||
|
|
||||||
|
if len(agents) == 0 {
|
||||||
|
log.Warn().Msg("No CHORUS agents discovered to broadcast opportunity to")
|
||||||
|
return fmt.Errorf("no agents available to receive broadcast")
|
||||||
|
}
|
||||||
|
|
||||||
|
successCount := 0
|
||||||
|
errorCount := 0
|
||||||
|
|
||||||
|
// Broadcast to each agent
|
||||||
|
for _, agent := range agents {
|
||||||
|
err := b.sendOpportunityToAgent(ctx, agent, opportunity)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().
|
||||||
|
Err(err).
|
||||||
|
Str("agent_id", agent.ID).
|
||||||
|
Str("endpoint", agent.Endpoint).
|
||||||
|
Msg("Failed to send opportunity to agent")
|
||||||
|
errorCount++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
successCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info().
|
||||||
|
Int("success_count", successCount).
|
||||||
|
Int("error_count", errorCount).
|
||||||
|
Int("total_agents", len(agents)).
|
||||||
|
Msg("✅ Council opportunity broadcast completed")
|
||||||
|
|
||||||
|
if successCount == 0 {
|
||||||
|
return fmt.Errorf("failed to broadcast to any agents")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendOpportunityToAgent sends a council opportunity to a specific CHORUS agent
|
||||||
|
func (b *Broadcaster) sendOpportunityToAgent(ctx context.Context, agent *Agent, opportunity *CouncilOpportunity) error {
|
||||||
|
// Construct the agent's opportunity endpoint
|
||||||
|
// CHORUS agents should expose /api/v1/opportunities endpoint to receive opportunities
|
||||||
|
opportunityURL := fmt.Sprintf("%s/api/v1/opportunities/council", agent.Endpoint)
|
||||||
|
|
||||||
|
// Marshal opportunity to JSON
|
||||||
|
payload, err := json.Marshal(opportunity)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal opportunity: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create HTTP request
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "POST", opportunityURL, bytes.NewBuffer(payload))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("X-WHOOSH-Broadcast", "council-opportunity")
|
||||||
|
req.Header.Set("X-Council-ID", opportunity.CouncilID.String())
|
||||||
|
|
||||||
|
// Send request with timeout
|
||||||
|
client := &http.Client{
|
||||||
|
Timeout: 10 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to send opportunity to agent: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted {
|
||||||
|
return fmt.Errorf("agent returned non-success status: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Debug().
|
||||||
|
Str("agent_id", agent.ID).
|
||||||
|
Str("council_id", opportunity.CouncilID.String()).
|
||||||
|
Int("status_code", resp.StatusCode).
|
||||||
|
Msg("Successfully sent council opportunity to agent")
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BroadcastAgentAssignment notifies an agent that they've been assigned to a council role
|
||||||
|
func (b *Broadcaster) BroadcastAgentAssignment(ctx context.Context, agentID string, assignment *AgentAssignment) error {
|
||||||
|
// Find the agent
|
||||||
|
agents := b.discovery.GetAgents()
|
||||||
|
var targetAgent *Agent
|
||||||
|
|
||||||
|
for _, agent := range agents {
|
||||||
|
if agent.ID == agentID {
|
||||||
|
targetAgent = agent
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if targetAgent == nil {
|
||||||
|
return fmt.Errorf("agent %s not found in discovery", agentID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send assignment to agent
|
||||||
|
assignmentURL := fmt.Sprintf("%s/api/v1/assignments/council", targetAgent.Endpoint)
|
||||||
|
|
||||||
|
payload, err := json.Marshal(assignment)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal assignment: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "POST", assignmentURL, bytes.NewBuffer(payload))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("X-WHOOSH-Broadcast", "council-assignment")
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 10 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to send assignment to agent: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted {
|
||||||
|
return fmt.Errorf("agent returned non-success status: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info().
|
||||||
|
Str("agent_id", agentID).
|
||||||
|
Str("council_id", assignment.CouncilID.String()).
|
||||||
|
Str("role", assignment.RoleName).
|
||||||
|
Msg("✅ Successfully notified agent of council assignment")
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BroadcastCouncilStatusUpdate notifies all discovered agents about council staffing status
|
||||||
|
func (b *Broadcaster) BroadcastCouncilStatusUpdate(ctx context.Context, update *CouncilStatusUpdate) error {
|
||||||
|
log.Info().
|
||||||
|
Str("council_id", update.CouncilID.String()).
|
||||||
|
Str("status", update.Status).
|
||||||
|
Msg("📢 Broadcasting council status update to CHORUS agents")
|
||||||
|
|
||||||
|
agents := b.discovery.GetAgents()
|
||||||
|
if len(agents) == 0 {
|
||||||
|
log.Warn().Str("council_id", update.CouncilID.String()).Msg("No CHORUS agents discovered for council status update")
|
||||||
|
return fmt.Errorf("no agents available to receive council status update")
|
||||||
|
}
|
||||||
|
|
||||||
|
successCount := 0
|
||||||
|
errorCount := 0
|
||||||
|
|
||||||
|
for _, agent := range agents {
|
||||||
|
if err := b.sendCouncilStatusToAgent(ctx, agent, update); err != nil {
|
||||||
|
log.Error().
|
||||||
|
Err(err).
|
||||||
|
Str("agent_id", agent.ID).
|
||||||
|
Str("council_id", update.CouncilID.String()).
|
||||||
|
Msg("Failed to send council status update to agent")
|
||||||
|
errorCount++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
successCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info().
|
||||||
|
Str("council_id", update.CouncilID.String()).
|
||||||
|
Int("success_count", successCount).
|
||||||
|
Int("error_count", errorCount).
|
||||||
|
Int("total_agents", len(agents)).
|
||||||
|
Msg("✅ Council status update broadcast completed")
|
||||||
|
|
||||||
|
if successCount == 0 {
|
||||||
|
return fmt.Errorf("failed to broadcast council status update to any agents")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Broadcaster) sendCouncilStatusToAgent(ctx context.Context, agent *Agent, update *CouncilStatusUpdate) error {
|
||||||
|
statusURL := fmt.Sprintf("%s/api/v1/councils/status", agent.Endpoint)
|
||||||
|
|
||||||
|
payload, err := json.Marshal(update)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal council status update: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "POST", statusURL, bytes.NewBuffer(payload))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create council status request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("X-WHOOSH-Broadcast", "council-status")
|
||||||
|
req.Header.Set("X-Council-ID", update.CouncilID.String())
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 10 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to send council status to agent: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted {
|
||||||
|
return fmt.Errorf("agent returned non-success status: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Debug().
|
||||||
|
Str("agent_id", agent.ID).
|
||||||
|
Str("council_id", update.CouncilID.String()).
|
||||||
|
Int("status_code", resp.StatusCode).
|
||||||
|
Msg("Successfully sent council status update to agent")
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AgentAssignment represents an assignment of an agent to a council role
|
||||||
|
type AgentAssignment struct {
|
||||||
|
CouncilID uuid.UUID `json:"council_id"`
|
||||||
|
ProjectName string `json:"project_name"`
|
||||||
|
RoleName string `json:"role_name"`
|
||||||
|
UCXLAddress string `json:"ucxl_address"`
|
||||||
|
ProjectBrief string `json:"project_brief"`
|
||||||
|
Repository string `json:"repository"`
|
||||||
|
AssignedAt time.Time `json:"assigned_at"`
|
||||||
|
Metadata map[string]interface{} `json:"metadata"`
|
||||||
|
}
|
||||||
@@ -179,8 +179,8 @@ func NewServer(cfg *config.Config, db *database.DB) (*Server, error) {
|
|||||||
log.Warn().Msg("🐳 Docker integration disabled - scaling system and council agent deployment unavailable")
|
log.Warn().Msg("🐳 Docker integration disabled - scaling system and council agent deployment unavailable")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize repository monitor with team composer, council composer, and agent deployer
|
// Initialize repository monitor with team composer, council composer, agent deployer, and broadcaster
|
||||||
repoMonitor := monitor.NewMonitor(db.Pool, cfg.GITEA, teamComposer, councilComposer, agentDeployer)
|
repoMonitor := monitor.NewMonitor(db.Pool, cfg.GITEA, teamComposer, councilComposer, agentDeployer, p2pBroadcaster)
|
||||||
|
|
||||||
s := &Server{
|
s := &Server{
|
||||||
config: cfg,
|
config: cfg,
|
||||||
|
|||||||
Reference in New Issue
Block a user