Files
goclaw/internal/tools/team_tool_validation.go
T
viettranx 49441f7305 refactor: remove dead delegate code, rename lane/channel to team/teammate
- Remove handleDelegateAnnounce() dead code (no sender emits delegate:* messages)
- Remove delegate tool reference from intent_classify.go
- Rename LaneDelegate → LaneTeam with backward-compat env var fallback
- Rename ChannelDelegate → ChannelTeammate across all team tool files
- Comment out lifecycle guards in team_tasks_lifecycle.go (TODO: reviewer workflow)
- Update string literals in cron.go, task_ticker.go
- Gate tool_status placeholder_update to non-streaming runs only
- Skip FinalizeStream on tool.call to prevent mid-run content loss
2026-03-18 11:04:45 +07:00

285 lines
9.0 KiB
Go

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
}