mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-06-10 10:10:49 +00:00
49441f7305
- 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
285 lines
9.0 KiB
Go
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
|
|
}
|