Files
goclaw/internal/tools/team_tasks_read.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

347 lines
11 KiB
Go

package tools
import (
"context"
"encoding/json"
"fmt"
"sync"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
const listPageSize = 30
// blockerSummary is a compact view of a blocker task for blocked_by resolution.
type blockerSummary struct {
ID uuid.UUID `json:"id"`
Subject string `json:"subject"`
Status string `json:"status"`
OwnerAgentKey string `json:"owner_agent_key,omitempty"`
}
// taskListItem is the slim view returned by list/search actions.
// Excludes UUIDs (owner_agent_id, created_by_agent_id) and task_number — model uses
// agent keys and identifier instead.
type taskListItem struct {
ID uuid.UUID `json:"id"`
Identifier string `json:"identifier"`
Subject string `json:"subject"`
Status string `json:"status"`
OwnerAgentKey string `json:"owner_agent_key,omitempty"`
OwnerDisplayName string `json:"owner_display_name,omitempty"`
CreatedByAgentKey string `json:"created_by_agent_key,omitempty"`
CreatedByDisplayName string `json:"created_by_display_name,omitempty"`
ProgressPercent int `json:"progress_percent,omitempty"`
ProgressStep string `json:"progress_step,omitempty"`
BlockedBy []blockerSummary `json:"blocked_by,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// taskDetailItem is the slim view returned by the get action.
type taskDetailItem struct {
ID uuid.UUID `json:"id"`
Identifier string `json:"identifier"`
Subject string `json:"subject"`
Description string `json:"description,omitempty"`
Status string `json:"status"`
Result *string `json:"result,omitempty"`
OwnerAgentKey string `json:"owner_agent_key,omitempty"`
OwnerDisplayName string `json:"owner_display_name,omitempty"`
CreatedByAgentKey string `json:"created_by_agent_key,omitempty"`
CreatedByDisplayName string `json:"created_by_display_name,omitempty"`
ProgressPercent int `json:"progress_percent,omitempty"`
ProgressStep string `json:"progress_step,omitempty"`
BlockedBy []blockerSummary `json:"blocked_by,omitempty"`
Priority int `json:"priority"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// slimComment is the slim comment view for get response.
type slimComment struct {
AgentKey string `json:"agent_key"`
Content string `json:"content"`
CreatedAt time.Time `json:"created_at"`
}
// slimEvent is the slim event view for get response.
type slimEvent struct {
EventType string `json:"event_type"`
ActorID string `json:"actor_id"`
CreatedAt time.Time `json:"created_at"`
}
// teamCreateLocks serializes list→create flows per (teamID:chatID) pair.
var teamCreateLocks sync.Map // key: "teamID:chatID" → *sync.Mutex
func getTeamCreateLock(teamID, chatID string) *sync.Mutex {
key := teamID + ":" + chatID
v, _ := teamCreateLocks.LoadOrStore(key, &sync.Mutex{})
return v.(*sync.Mutex)
}
// resolveBlockers batch-loads blocker tasks and returns slim summaries.
func (t *TeamTasksTool) resolveBlockers(ctx context.Context, blockedBy []uuid.UUID) []blockerSummary {
if len(blockedBy) == 0 {
return nil
}
tasks, err := t.manager.teamStore.GetTasksByIDs(ctx, blockedBy)
if err != nil {
return nil
}
out := make([]blockerSummary, 0, len(tasks))
for _, task := range tasks {
out = append(out, blockerSummary{
ID: task.ID,
Subject: task.Subject,
Status: task.Status,
OwnerAgentKey: task.OwnerAgentKey,
})
}
return out
}
func (t *TeamTasksTool) toListItem(ctx context.Context, task store.TeamTaskData) taskListItem {
return taskListItem{
ID: task.ID,
Identifier: task.Identifier,
Subject: task.Subject,
Status: task.Status,
OwnerAgentKey: task.OwnerAgentKey,
OwnerDisplayName: t.manager.agentDisplayName(ctx, task.OwnerAgentKey),
CreatedByAgentKey: task.CreatedByAgentKey,
CreatedByDisplayName: t.manager.agentDisplayName(ctx, task.CreatedByAgentKey),
ProgressPercent: task.ProgressPercent,
ProgressStep: task.ProgressStep,
BlockedBy: t.resolveBlockers(ctx, task.BlockedBy),
CreatedAt: task.CreatedAt,
}
}
func (t *TeamTasksTool) toDetailItem(ctx context.Context, task *store.TeamTaskData) taskDetailItem {
return taskDetailItem{
ID: task.ID,
Identifier: task.Identifier,
Subject: task.Subject,
Description: task.Description,
Status: task.Status,
Result: task.Result,
OwnerAgentKey: task.OwnerAgentKey,
OwnerDisplayName: t.manager.agentDisplayName(ctx, task.OwnerAgentKey),
CreatedByAgentKey: task.CreatedByAgentKey,
CreatedByDisplayName: t.manager.agentDisplayName(ctx, task.CreatedByAgentKey),
ProgressPercent: task.ProgressPercent,
ProgressStep: task.ProgressStep,
BlockedBy: t.resolveBlockers(ctx, task.BlockedBy),
Priority: task.Priority,
CreatedAt: task.CreatedAt,
UpdatedAt: task.UpdatedAt,
}
}
func (t *TeamTasksTool) executeList(ctx context.Context, args map[string]any) *Result {
team, _, err := t.manager.resolveTeam(ctx)
if err != nil {
return ErrorResult(err.Error())
}
statusFilter, _ := args["status"].(string)
page := 1
if p, ok := args["page"].(float64); ok && int(p) > 1 {
page = int(p)
}
offset := (page - 1) * listPageSize
// Teammate/system channels see all tasks; end users only see their own.
filterUserID := ""
channel := ToolChannelFromCtx(ctx)
if channel != ChannelTeammate && channel != ChannelSystem {
filterUserID = store.UserIDFromContext(ctx)
}
chatID := ToolChatIDFromCtx(ctx)
// Shared workspace: show all tasks across chats.
listChatID := chatID
if IsSharedWorkspace(team.Settings) {
listChatID = ""
}
// Acquire team create lock to serialize list→create flows across concurrent goroutines.
if ptd := PendingTeamDispatchFromCtx(ctx); ptd != nil && !ptd.HasListed() {
lock := getTeamCreateLock(team.ID.String(), chatID)
lock.Lock()
ptd.SetTeamLock(lock)
ptd.MarkListed()
}
tasks, err := t.manager.teamStore.ListTasks(ctx, team.ID, "priority", statusFilter, filterUserID, "", listChatID, 0, offset)
if err != nil {
return ErrorResult("failed to list tasks: " + err.Error())
}
hasMore := len(tasks) > listPageSize
if hasMore {
tasks = tasks[:listPageSize]
}
items := make([]taskListItem, 0, len(tasks))
for _, task := range tasks {
items = append(items, t.toListItem(ctx, task))
}
resp := map[string]any{
"tasks": items,
"count": len(items),
"page": page,
}
if hasMore {
resp["has_more"] = true
}
out, _ := json.Marshal(resp)
return SilentResult(string(out))
}
// resolveTaskID extracts and validates the task_id from tool arguments.
// Falls back to the dispatched task ID from context when task_id is empty or
// not a valid UUID (agents often pass task_number like "1" instead of the UUID).
func resolveTaskID(ctx context.Context, args map[string]any) (uuid.UUID, error) {
taskIDStr, _ := args["task_id"].(string)
// Try parsing as UUID first.
if taskIDStr != "" {
if id, err := uuid.Parse(taskIDStr); err == nil {
return id, nil
}
}
// Fall back to the dispatched team task ID from context.
if ctxID := TeamTaskIDFromCtx(ctx); ctxID != "" {
if id, err := uuid.Parse(ctxID); err == nil {
return id, nil
}
}
if taskIDStr == "" {
return uuid.Nil, fmt.Errorf("task_id is required")
}
return uuid.Nil, fmt.Errorf("invalid task_id %q — use the UUID from task list, not the task number", taskIDStr)
}
func (t *TeamTasksTool) executeGet(ctx context.Context, args map[string]any) *Result {
team, _, err := t.manager.resolveTeam(ctx)
if err != nil {
return ErrorResult(err.Error())
}
taskID, err := resolveTaskID(ctx, args)
if err != nil {
return ErrorResult(err.Error())
}
task, err := t.manager.teamStore.GetTask(ctx, taskID)
if err != nil {
return ErrorResult("failed to get task: " + err.Error())
}
if task.TeamID != team.ID {
return ErrorResult("task does not belong to your team")
}
// Truncate result for context protection
const maxResultRunes = 8000
if task.Result != nil {
r := []rune(*task.Result)
if len(r) > maxResultRunes {
s := string(r[:maxResultRunes]) + "..."
task.Result = &s
}
}
detail := t.toDetailItem(ctx, task)
// Load and slim comments/events/attachments
resp := map[string]any{"task": detail}
if comments, _ := t.manager.teamStore.ListTaskComments(ctx, taskID); len(comments) > 0 {
slim := make([]slimComment, 0, len(comments))
for _, c := range comments {
key := ""
if c.AgentID != nil {
key = t.manager.agentKeyFromID(ctx, *c.AgentID)
}
slim = append(slim, slimComment{
AgentKey: key,
Content: c.Content,
CreatedAt: c.CreatedAt,
})
}
resp["comments"] = slim
}
if events, _ := t.manager.teamStore.ListTaskEvents(ctx, taskID); len(events) > 0 {
slim := make([]slimEvent, 0, len(events))
for _, e := range events {
slim = append(slim, slimEvent{
EventType: e.EventType,
ActorID: e.ActorID,
CreatedAt: e.CreatedAt,
})
}
resp["events"] = slim
}
if attachments, _ := t.manager.teamStore.ListTaskAttachments(ctx, taskID); len(attachments) > 0 {
resp["attachments"] = attachments
}
out, _ := json.Marshal(resp)
return SilentResult(string(out))
}
func (t *TeamTasksTool) executeSearch(ctx context.Context, args map[string]any) *Result {
team, _, err := t.manager.resolveTeam(ctx)
if err != nil {
return ErrorResult(err.Error())
}
query, _ := args["query"].(string)
if query == "" {
return ErrorResult("query is required for search action")
}
// Teammate/system channels see all tasks; end users only see their own.
filterUserID := ""
channel := ToolChannelFromCtx(ctx)
if channel != ChannelTeammate && channel != ChannelSystem {
filterUserID = store.UserIDFromContext(ctx)
}
// Acquire team create lock so search also satisfies the list-before-create gate.
chatID := ToolChatIDFromCtx(ctx)
if ptd := PendingTeamDispatchFromCtx(ctx); ptd != nil && !ptd.HasListed() {
lock := getTeamCreateLock(team.ID.String(), chatID)
lock.Lock()
ptd.SetTeamLock(lock)
ptd.MarkListed()
}
tasks, err := t.manager.teamStore.SearchTasks(ctx, team.ID, query, listPageSize, filterUserID)
if err != nil {
return ErrorResult("failed to search tasks: " + err.Error())
}
items := make([]taskListItem, 0, len(tasks))
for _, task := range tasks {
items = append(items, t.toListItem(ctx, task))
}
out, _ := json.Marshal(map[string]any{
"tasks": items,
"count": len(items),
})
return SilentResult(string(out))
}