package tools import ( "context" "fmt" "log/slog" "strings" "time" "github.com/google/uuid" "github.com/nextlevelbuilder/goclaw/internal/bus" "github.com/nextlevelbuilder/goclaw/internal/store" "github.com/nextlevelbuilder/goclaw/pkg/protocol" ) // PostTurnProcessor validates and dispatches pending team tasks after an agent turn. type PostTurnProcessor interface { ProcessPendingTasks(ctx context.Context, teamID uuid.UUID, taskIDs []uuid.UUID) error // DispatchUnblockedTasks finds pending tasks with an owner and dispatches them. // Called by the consumer after auto-completing a task to unblock dependent work. DispatchUnblockedTasks(ctx context.Context, teamID uuid.UUID) } // ProcessPendingTasks validates tasks created during a turn and dispatches unblocked ones. // Called by the consumer after an agent turn ends. func (m *TeamToolManager) ProcessPendingTasks(ctx context.Context, teamID uuid.UUID, taskIDs []uuid.UUID) error { if len(taskIDs) == 0 { return nil } // Fetch all tasks created in this turn. tasks := make([]*store.TeamTaskData, 0, len(taskIDs)) for _, id := range taskIDs { t, err := m.teamStore.GetTask(ctx, id) if err != nil { slog.Warn("post_turn: cannot fetch task", "task_id", id, "error", err) continue } tasks = append(tasks, t) } if len(tasks) == 0 { return nil } // Build lookup: taskID → task (for cycle/ref validation). taskMap := make(map[uuid.UUID]*store.TeamTaskData, len(tasks)) for _, t := range tasks { taskMap[t.ID] = t } // Validate blocked_by references and detect cycles. cycled, invalidRef := validateBlockedBy(taskMap) // Fail tasks with invalid blocked_by references. for taskID, badRef := range invalidRef { task := taskMap[taskID] errMsg := fmt.Sprintf("blocked_by references non-existent task %s", badRef) if err := m.teamStore.FailPendingTask(ctx, taskID, teamID, errMsg); err != nil { slog.Warn("post_turn: FailPendingTask error", "task_id", taskID, "error", err) } m.broadcastTeamEvent(protocol.EventTeamTaskFailed, protocol.TeamTaskEventPayload{ TeamID: teamID.String(), TaskID: taskID.String(), Status: store.TeamTaskStatusFailed, Timestamp: time.Now().UTC().Format("2006-01-02T15:04:05Z"), ActorType: "system", ActorID: "post_turn", }) slog.Warn("post_turn: task failed — invalid blocked_by", "task_id", taskID, "identifier", task.Identifier, "bad_ref", badRef) } // Fail cycled tasks and notify leader. if len(cycled) > 0 { m.failCycledTasks(ctx, teamID, cycled, taskMap) } // Dispatch pending assigned tasks (not blocked, not failed). for _, task := range tasks { if _, isCycled := cycled[task.ID]; isCycled { continue } if _, isInvalid := invalidRef[task.ID]; isInvalid { continue } if task.Status != store.TeamTaskStatusPending || task.OwnerAgentID == nil { continue } if err := m.teamStore.AssignTask(ctx, task.ID, *task.OwnerAgentID, teamID); err != nil { slog.Warn("post_turn: assign failed", "task_id", task.ID, "error", err) continue } m.broadcastTeamEvent(protocol.EventTeamTaskDispatched, protocol.TeamTaskEventPayload{ TeamID: teamID.String(), TaskID: task.ID.String(), TaskNumber: task.TaskNumber, Subject: task.Subject, Status: store.TeamTaskStatusInProgress, OwnerAgentKey: m.agentKeyFromID(ctx, *task.OwnerAgentID), Channel: task.Channel, ChatID: task.ChatID, Timestamp: time.Now().UTC().Format("2006-01-02T15:04:05Z"), ActorType: "system", ActorID: "post_turn", }) // Restore leader's trace context from task metadata (ctx here is the // consumer goroutine context which has no trace after the turn ends). dispatchCtx := m.restoreTraceContext(ctx, task) m.dispatchTaskToAgent(dispatchCtx, task, teamID, *task.OwnerAgentID) } slog.Info("post_turn: processed pending tasks", "team_id", teamID, "total", len(tasks), "dispatched", countPendingAssigned(tasks, cycled, invalidRef), ) return nil } // failCycledTasks fails all tasks in the cycle and notifies the leader. func (m *TeamToolManager) failCycledTasks(ctx context.Context, teamID uuid.UUID, cycled map[uuid.UUID]bool, taskMap map[uuid.UUID]*store.TeamTaskData) { // Build cycle description using task identifiers. var ids []string for id := range cycled { if t := taskMap[id]; t != nil { ids = append(ids, t.Identifier) } } cycleDesc := fmt.Sprintf("Circular dependency detected among tasks: %s", strings.Join(ids, " → ")) for id := range cycled { if err := m.teamStore.FailPendingTask(ctx, id, teamID, cycleDesc); err != nil { slog.Warn("post_turn: FailPendingTask (cycle) error", "task_id", id, "error", err) } m.broadcastTeamEvent(protocol.EventTeamTaskFailed, protocol.TeamTaskEventPayload{ TeamID: teamID.String(), TaskID: id.String(), Status: store.TeamTaskStatusFailed, Timestamp: time.Now().UTC().Format("2006-01-02T15:04:05Z"), ActorType: "system", ActorID: "post_turn", }) } // Notify leader via system message. m.notifyLeaderCycleError(ctx, teamID, cycleDesc) } // notifyLeaderCycleError sends a system message to the leader about cycled tasks. // Uses "notification:" sender prefix to go through normal consumer flow (not handleTeammateMessage). func (m *TeamToolManager) notifyLeaderCycleError(ctx context.Context, teamID uuid.UUID, cycleDesc string) { if m.msgBus == nil { return } team, err := m.teamStore.GetTeam(ctx, teamID) if err != nil { return } leadAgent, err := m.cachedGetAgentByID(ctx, team.LeadAgentID) if err != nil { return } content := fmt.Sprintf("[System] %s\nPlease recreate these tasks with corrected dependencies.\nUse team_tasks(action=\"list\") to view current task board.", cycleDesc) // Resolve routing: use context channel/chatID if available, fallback to dashboard. channel := ToolChannelFromCtx(ctx) chatID := ToolChatIDFromCtx(ctx) if channel == "" || channel == ChannelSystem || channel == ChannelTeammate { channel = "dashboard" chatID = teamID.String() } m.msgBus.TryPublishInbound(bus.InboundMessage{ Channel: channel, SenderID: "notification:system", ChatID: chatID, AgentID: leadAgent.AgentKey, UserID: team.CreatedBy, Content: content, }) } // validateBlockedBy checks blocked_by references and detects cycles using Kahn's algorithm. // Only validates within the batch — out-of-batch blocked_by refs are assumed valid // (already validated by executeCreate). Self-blocking is caught as invalid. // // Note: Cross-turn blocked_by references (tasks from previous turns) work at the DB // level via unblockDependentTasks (WHERE $1 = ANY(blocked_by)). Cycle detection here // only considers edges within the current batch — cross-turn deps are external. // // Returns: cycled task IDs, and map of taskID → first invalid blocked_by ref. func validateBlockedBy(taskMap map[uuid.UUID]*store.TeamTaskData) (cycled map[uuid.UUID]bool, invalidRef map[uuid.UUID]uuid.UUID) { invalidRef = make(map[uuid.UUID]uuid.UUID) // Collect all task IDs in this batch. batchIDs := make(map[uuid.UUID]bool, len(taskMap)) for id := range taskMap { batchIDs[id] = true } // Check for self-blocking only. Out-of-batch blocked_by refs are valid // (tasks from previous turns, already validated by executeCreate). for id, task := range taskMap { for _, dep := range task.BlockedBy { if dep == id { invalidRef[id] = dep break } } } // Cycle detection via Kahn's algorithm. // Only considers edges within the batch — out-of-batch deps are external // and don't participate in cycle detection. inDegree := make(map[uuid.UUID]int) adj := make(map[uuid.UUID][]uuid.UUID) // blocker → dependents for id, task := range taskMap { if _, bad := invalidRef[id]; bad { continue } if _, exists := inDegree[id]; !exists { inDegree[id] = 0 } for _, dep := range task.BlockedBy { if _, bad := invalidRef[dep]; bad { continue } // Only consider edges within the batch for cycle detection. if !batchIDs[dep] { continue } adj[dep] = append(adj[dep], id) inDegree[id]++ } } // BFS: process nodes with in-degree 0. var queue []uuid.UUID for id, deg := range inDegree { if deg == 0 { queue = append(queue, id) } } processed := make(map[uuid.UUID]bool) for len(queue) > 0 { node := queue[0] queue = queue[1:] processed[node] = true for _, dependent := range adj[node] { inDegree[dependent]-- if inDegree[dependent] == 0 { queue = append(queue, dependent) } } } // Any node not processed is part of a cycle. cycled = make(map[uuid.UUID]bool) for id := range inDegree { if !processed[id] { cycled[id] = true } } return cycled, invalidRef } // countPendingAssigned counts tasks that were eligible for dispatch. func countPendingAssigned(tasks []*store.TeamTaskData, cycled map[uuid.UUID]bool, invalidRef map[uuid.UUID]uuid.UUID) int { n := 0 for _, t := range tasks { if _, c := cycled[t.ID]; c { continue } if _, i := invalidRef[t.ID]; i { continue } if t.Status == store.TeamTaskStatusPending && t.OwnerAgentID != nil { n++ } } return n }