From 0151ffcaeb650de55249b89e1cc7e0224158b311 Mon Sep 17 00:00:00 2001 From: Bruno Bornsztein Date: Fri, 6 Feb 2026 09:55:18 -0600 Subject: [PATCH] Add agent teams support for task delegation and orchestration Enable AI agents to delegate subtasks to other agents via parent-child task relationships. Adds parent_id column to tasks, three new MCP tools (taskyou_delegate_task, taskyou_get_team_status, taskyou_wait_for_task), team progress indicators in the kanban UI, and comprehensive tests. Co-Authored-By: Claude Opus 4.6 --- internal/db/dependencies.go | 7 +- internal/db/sqlite.go | 5 + internal/db/tasks.go | 36 +++-- internal/db/teams.go | 167 ++++++++++++++++++++ internal/db/teams_test.go | 307 ++++++++++++++++++++++++++++++++++++ internal/mcp/server.go | 251 +++++++++++++++++++++++++++++ internal/ui/app.go | 11 +- internal/ui/kanban.go | 40 +++++ 8 files changed, 809 insertions(+), 15 deletions(-) create mode 100644 internal/db/teams.go create mode 100644 internal/db/teams_test.go diff --git a/internal/db/dependencies.go b/internal/db/dependencies.go index 636e1348..d701850e 100644 --- a/internal/db/dependencies.go +++ b/internal/db/dependencies.go @@ -111,7 +111,8 @@ func (db *DB) GetBlockers(taskID int64) ([]*Task, error) { COALESCE(t.pr_url, ''), COALESCE(t.pr_number, 0), COALESCE(t.dangerous_mode, 0), COALESCE(t.pinned, 0), COALESCE(t.tags, ''), COALESCE(t.summary, ''), t.created_at, t.updated_at, t.started_at, t.completed_at, - t.last_distilled_at, t.last_accessed_at + t.last_distilled_at, t.last_accessed_at, + COALESCE(t.parent_id, 0) FROM tasks t JOIN task_dependencies d ON t.id = d.blocker_id WHERE d.blocked_id = ? @@ -135,7 +136,8 @@ func (db *DB) GetBlockedBy(taskID int64) ([]*Task, error) { COALESCE(t.pr_url, ''), COALESCE(t.pr_number, 0), COALESCE(t.dangerous_mode, 0), COALESCE(t.pinned, 0), COALESCE(t.tags, ''), COALESCE(t.summary, ''), t.created_at, t.updated_at, t.started_at, t.completed_at, - t.last_distilled_at, t.last_accessed_at + t.last_distilled_at, t.last_accessed_at, + COALESCE(t.parent_id, 0) FROM tasks t JOIN task_dependencies d ON t.id = d.blocked_id WHERE d.blocker_id = ? @@ -310,6 +312,7 @@ func scanTaskRows(rows *sql.Rows) ([]*Task, error) { &t.DangerousMode, &t.Pinned, &t.Tags, &t.Summary, &t.CreatedAt, &t.UpdatedAt, &t.StartedAt, &t.CompletedAt, &t.LastDistilledAt, &t.LastAccessedAt, + &t.ParentID, ) if err != nil { return nil, fmt.Errorf("scan task: %w", err) diff --git a/internal/db/sqlite.go b/internal/db/sqlite.go index cde69a72..cf7fc908 100644 --- a/internal/db/sqlite.go +++ b/internal/db/sqlite.go @@ -251,6 +251,8 @@ func (db *DB) migrate() error { `ALTER TABLE tasks ADD COLUMN archive_branch_name TEXT DEFAULT ''`, // Original branch name before archiving // Source branch for checking out existing branches in worktrees (e.g., for QA deployments) `ALTER TABLE tasks ADD COLUMN source_branch TEXT DEFAULT ''`, // Existing branch to checkout instead of creating new branch + // Agent teams - parent-child task relationships + `ALTER TABLE tasks ADD COLUMN parent_id INTEGER DEFAULT 0`, // Parent task ID (0 = no parent) } for _, m := range alterMigrations { @@ -258,6 +260,9 @@ func (db *DB) migrate() error { db.Exec(m) } + // Index for parent_id (agent teams) + db.Exec(`CREATE INDEX IF NOT EXISTS idx_tasks_parent_id ON tasks(parent_id)`) + // Note: SQLite doesn't support ALTER COLUMN DEFAULT directly // The default value change for project column will be handled in the application layer // New tasks will get 'personal' as default through the form and executor logic diff --git a/internal/db/tasks.go b/internal/db/tasks.go index 08674626..d4bd101b 100644 --- a/internal/db/tasks.go +++ b/internal/db/tasks.go @@ -46,6 +46,8 @@ type Task struct { ArchiveCommit string // Commit hash at time of archiving ArchiveWorktreePath string // Original worktree path before archiving ArchiveBranchName string // Original branch name before archiving + // Agent teams - parent-child task relationships + ParentID int64 // Parent task ID (0 = no parent, this is a top-level task) } // Task statuses @@ -128,9 +130,9 @@ func (db *DB) CreateTask(t *Task) error { t.Project = project.Name result, err := db.Exec(` - INSERT INTO tasks (title, body, status, type, project, executor, pinned, tags, source_branch) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - `, t.Title, t.Body, t.Status, t.Type, t.Project, t.Executor, t.Pinned, t.Tags, t.SourceBranch) + INSERT INTO tasks (title, body, status, type, project, executor, pinned, tags, source_branch, parent_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, t.Title, t.Body, t.Status, t.Type, t.Project, t.Executor, t.Pinned, t.Tags, t.SourceBranch, t.ParentID) if err != nil { return fmt.Errorf("insert task: %w", err) } @@ -179,7 +181,8 @@ func (db *DB) GetTask(id int64) (*Task, error) { created_at, updated_at, started_at, completed_at, last_distilled_at, last_accessed_at, COALESCE(archive_ref, ''), COALESCE(archive_commit, ''), - COALESCE(archive_worktree_path, ''), COALESCE(archive_branch_name, '') + COALESCE(archive_worktree_path, ''), COALESCE(archive_branch_name, ''), + COALESCE(parent_id, 0) FROM tasks WHERE id = ? `, id).Scan( &t.ID, &t.Title, &t.Body, &t.Status, &t.Type, &t.Project, &t.Executor, @@ -191,6 +194,7 @@ func (db *DB) GetTask(id int64) (*Task, error) { &t.CreatedAt, &t.UpdatedAt, &t.StartedAt, &t.CompletedAt, &t.LastDistilledAt, &t.LastAccessedAt, &t.ArchiveRef, &t.ArchiveCommit, &t.ArchiveWorktreePath, &t.ArchiveBranchName, + &t.ParentID, ) if err == sql.ErrNoRows { return nil, nil @@ -224,7 +228,8 @@ func (db *DB) ListTasks(opts ListTasksOptions) ([]*Task, error) { created_at, updated_at, started_at, completed_at, last_distilled_at, last_accessed_at, COALESCE(archive_ref, ''), COALESCE(archive_commit, ''), - COALESCE(archive_worktree_path, ''), COALESCE(archive_branch_name, '') + COALESCE(archive_worktree_path, ''), COALESCE(archive_branch_name, ''), + COALESCE(parent_id, 0) FROM tasks WHERE 1=1 ` args := []interface{}{} @@ -284,6 +289,7 @@ func (db *DB) ListTasks(opts ListTasksOptions) ([]*Task, error) { &t.CreatedAt, &t.UpdatedAt, &t.StartedAt, &t.CompletedAt, &t.LastDistilledAt, &t.LastAccessedAt, &t.ArchiveRef, &t.ArchiveCommit, &t.ArchiveWorktreePath, &t.ArchiveBranchName, + &t.ParentID, ) if err != nil { return nil, fmt.Errorf("scan task: %w", err) @@ -309,7 +315,8 @@ func (db *DB) GetMostRecentlyCreatedTask() (*Task, error) { created_at, updated_at, started_at, completed_at, last_distilled_at, last_accessed_at, COALESCE(archive_ref, ''), COALESCE(archive_commit, ''), - COALESCE(archive_worktree_path, ''), COALESCE(archive_branch_name, '') + COALESCE(archive_worktree_path, ''), COALESCE(archive_branch_name, ''), + COALESCE(parent_id, 0) FROM tasks ORDER BY created_at DESC, id DESC LIMIT 1 @@ -323,6 +330,7 @@ func (db *DB) GetMostRecentlyCreatedTask() (*Task, error) { &t.CreatedAt, &t.UpdatedAt, &t.StartedAt, &t.CompletedAt, &t.LastDistilledAt, &t.LastAccessedAt, &t.ArchiveRef, &t.ArchiveCommit, &t.ArchiveWorktreePath, &t.ArchiveBranchName, + &t.ParentID, ) if err == sql.ErrNoRows { return nil, nil @@ -352,7 +360,8 @@ func (db *DB) SearchTasks(query string, limit int) ([]*Task, error) { created_at, updated_at, started_at, completed_at, last_distilled_at, last_accessed_at, COALESCE(archive_ref, ''), COALESCE(archive_commit, ''), - COALESCE(archive_worktree_path, ''), COALESCE(archive_branch_name, '') + COALESCE(archive_worktree_path, ''), COALESCE(archive_branch_name, ''), + COALESCE(parent_id, 0) FROM tasks WHERE ( title LIKE ? COLLATE NOCASE @@ -385,6 +394,7 @@ func (db *DB) SearchTasks(query string, limit int) ([]*Task, error) { &t.CreatedAt, &t.UpdatedAt, &t.StartedAt, &t.CompletedAt, &t.LastDistilledAt, &t.LastAccessedAt, &t.ArchiveRef, &t.ArchiveCommit, &t.ArchiveWorktreePath, &t.ArchiveBranchName, + &t.ParentID, ) if err != nil { return nil, fmt.Errorf("scan task: %w", err) @@ -728,7 +738,8 @@ func (db *DB) GetNextQueuedTask() (*Task, error) { created_at, updated_at, started_at, completed_at, last_distilled_at, last_accessed_at, COALESCE(archive_ref, ''), COALESCE(archive_commit, ''), - COALESCE(archive_worktree_path, ''), COALESCE(archive_branch_name, '') + COALESCE(archive_worktree_path, ''), COALESCE(archive_branch_name, ''), + COALESCE(parent_id, 0) FROM tasks WHERE status = ? ORDER BY created_at ASC @@ -743,6 +754,7 @@ func (db *DB) GetNextQueuedTask() (*Task, error) { &t.CreatedAt, &t.UpdatedAt, &t.StartedAt, &t.CompletedAt, &t.LastDistilledAt, &t.LastAccessedAt, &t.ArchiveRef, &t.ArchiveCommit, &t.ArchiveWorktreePath, &t.ArchiveBranchName, + &t.ParentID, ) if err == sql.ErrNoRows { return nil, nil @@ -766,7 +778,8 @@ func (db *DB) GetQueuedTasks() ([]*Task, error) { created_at, updated_at, started_at, completed_at, last_distilled_at, last_accessed_at, COALESCE(archive_ref, ''), COALESCE(archive_commit, ''), - COALESCE(archive_worktree_path, ''), COALESCE(archive_branch_name, '') + COALESCE(archive_worktree_path, ''), COALESCE(archive_branch_name, ''), + COALESCE(parent_id, 0) FROM tasks WHERE status = ? ORDER BY created_at ASC @@ -789,6 +802,7 @@ func (db *DB) GetQueuedTasks() ([]*Task, error) { &t.CreatedAt, &t.UpdatedAt, &t.StartedAt, &t.CompletedAt, &t.LastDistilledAt, &t.LastAccessedAt, &t.ArchiveRef, &t.ArchiveCommit, &t.ArchiveWorktreePath, &t.ArchiveBranchName, + &t.ParentID, ); err != nil { return nil, fmt.Errorf("scan task: %w", err) } @@ -811,7 +825,8 @@ func (db *DB) GetTasksWithBranches() ([]*Task, error) { created_at, updated_at, started_at, completed_at, last_distilled_at, last_accessed_at, COALESCE(archive_ref, ''), COALESCE(archive_commit, ''), - COALESCE(archive_worktree_path, ''), COALESCE(archive_branch_name, '') + COALESCE(archive_worktree_path, ''), COALESCE(archive_branch_name, ''), + COALESCE(parent_id, 0) FROM tasks WHERE branch_name != '' AND status NOT IN (?, ?) ORDER BY created_at DESC @@ -834,6 +849,7 @@ func (db *DB) GetTasksWithBranches() ([]*Task, error) { &t.CreatedAt, &t.UpdatedAt, &t.StartedAt, &t.CompletedAt, &t.LastDistilledAt, &t.LastAccessedAt, &t.ArchiveRef, &t.ArchiveCommit, &t.ArchiveWorktreePath, &t.ArchiveBranchName, + &t.ParentID, ); err != nil { return nil, fmt.Errorf("scan task: %w", err) } diff --git a/internal/db/teams.go b/internal/db/teams.go new file mode 100644 index 00000000..b40acc52 --- /dev/null +++ b/internal/db/teams.go @@ -0,0 +1,167 @@ +package db + +import ( + "fmt" +) + +// TeamStatus summarizes the progress of a task's child team. +type TeamStatus struct { + Total int `json:"total"` + Queued int `json:"queued"` + Processing int `json:"processing"` + Blocked int `json:"blocked"` + Done int `json:"done"` + Backlog int `json:"backlog"` +} + +// IsComplete returns true if all child tasks are done. +func (ts *TeamStatus) IsComplete() bool { + return ts.Total > 0 && ts.Done == ts.Total +} + +// ActiveCount returns the number of non-done tasks. +func (ts *TeamStatus) ActiveCount() int { + return ts.Total - ts.Done +} + +// GetChildTasks returns all tasks whose parent_id matches the given task ID. +func (db *DB) GetChildTasks(parentID int64) ([]*Task, error) { + rows, err := db.Query(` + SELECT id, title, body, status, type, project, COALESCE(executor, 'claude'), + worktree_path, branch_name, port, claude_session_id, + COALESCE(daemon_session, ''), COALESCE(tmux_window_id, ''), + COALESCE(claude_pane_id, ''), COALESCE(shell_pane_id, ''), + COALESCE(pr_url, ''), COALESCE(pr_number, 0), + COALESCE(dangerous_mode, 0), COALESCE(pinned, 0), COALESCE(tags, ''), + COALESCE(source_branch, ''), COALESCE(summary, ''), + created_at, updated_at, started_at, completed_at, + last_distilled_at, last_accessed_at, + COALESCE(archive_ref, ''), COALESCE(archive_commit, ''), + COALESCE(archive_worktree_path, ''), COALESCE(archive_branch_name, ''), + COALESCE(parent_id, 0) + FROM tasks + WHERE parent_id = ? + ORDER BY created_at ASC + `, parentID) + if err != nil { + return nil, fmt.Errorf("get child tasks: %w", err) + } + defer rows.Close() + + var tasks []*Task + for rows.Next() { + t := &Task{} + err := rows.Scan( + &t.ID, &t.Title, &t.Body, &t.Status, &t.Type, &t.Project, &t.Executor, + &t.WorktreePath, &t.BranchName, &t.Port, &t.ClaudeSessionID, + &t.DaemonSession, &t.TmuxWindowID, &t.ClaudePaneID, &t.ShellPaneID, + &t.PRURL, &t.PRNumber, + &t.DangerousMode, &t.Pinned, &t.Tags, + &t.SourceBranch, &t.Summary, + &t.CreatedAt, &t.UpdatedAt, &t.StartedAt, &t.CompletedAt, + &t.LastDistilledAt, &t.LastAccessedAt, + &t.ArchiveRef, &t.ArchiveCommit, &t.ArchiveWorktreePath, &t.ArchiveBranchName, + &t.ParentID, + ) + if err != nil { + return nil, fmt.Errorf("scan child task: %w", err) + } + tasks = append(tasks, t) + } + + return tasks, nil +} + +// GetTeamStatus returns an aggregate status of all child tasks for a parent. +func (db *DB) GetTeamStatus(parentID int64) (*TeamStatus, error) { + rows, err := db.Query(` + SELECT status, COUNT(*) as cnt + FROM tasks + WHERE parent_id = ? + GROUP BY status + `, parentID) + if err != nil { + return nil, fmt.Errorf("get team status: %w", err) + } + defer rows.Close() + + ts := &TeamStatus{} + for rows.Next() { + var status string + var count int + if err := rows.Scan(&status, &count); err != nil { + return nil, fmt.Errorf("scan team status: %w", err) + } + ts.Total += count + switch status { + case StatusQueued: + ts.Queued = count + case StatusProcessing: + ts.Processing = count + case StatusBlocked: + ts.Blocked = count + case StatusDone, StatusArchived: + ts.Done += count + case StatusBacklog: + ts.Backlog = count + } + } + + return ts, nil +} + +// HasChildTasks returns true if the task has any child tasks. +func (db *DB) HasChildTasks(taskID int64) (bool, error) { + var count int + err := db.QueryRow(`SELECT COUNT(*) FROM tasks WHERE parent_id = ?`, taskID).Scan(&count) + if err != nil { + return false, fmt.Errorf("count child tasks: %w", err) + } + return count > 0, nil +} + +// GetTeamStatusMap returns a map of parent task IDs to their team statuses. +// This is used by the UI to efficiently display team indicators on all parent tasks. +func (db *DB) GetTeamStatusMap() (map[int64]*TeamStatus, error) { + rows, err := db.Query(` + SELECT parent_id, status, COUNT(*) as cnt + FROM tasks + WHERE parent_id > 0 + GROUP BY parent_id, status + `) + if err != nil { + return nil, fmt.Errorf("get team status map: %w", err) + } + defer rows.Close() + + result := make(map[int64]*TeamStatus) + for rows.Next() { + var parentID int64 + var status string + var count int + if err := rows.Scan(&parentID, &status, &count); err != nil { + return nil, fmt.Errorf("scan team status map: %w", err) + } + + ts, ok := result[parentID] + if !ok { + ts = &TeamStatus{} + result[parentID] = ts + } + ts.Total += count + switch status { + case StatusQueued: + ts.Queued = count + case StatusProcessing: + ts.Processing = count + case StatusBlocked: + ts.Blocked = count + case StatusDone, StatusArchived: + ts.Done += count + case StatusBacklog: + ts.Backlog = count + } + } + + return result, nil +} diff --git a/internal/db/teams_test.go b/internal/db/teams_test.go new file mode 100644 index 00000000..455ef94e --- /dev/null +++ b/internal/db/teams_test.go @@ -0,0 +1,307 @@ +package db + +import ( + "os" + "testing" +) + +func setupTeamsTestDB(t *testing.T) (*DB, func()) { + tmpFile, err := os.CreateTemp("", "test-teams-*.db") + if err != nil { + t.Fatalf("Failed to create temp file: %v", err) + } + tmpFile.Close() + + db, err := Open(tmpFile.Name()) + if err != nil { + os.Remove(tmpFile.Name()) + t.Fatalf("Failed to open database: %v", err) + } + + cleanup := func() { + db.Close() + os.Remove(tmpFile.Name()) + } + + return db, cleanup +} + +func TestGetChildTasks(t *testing.T) { + db, cleanup := setupTeamsTestDB(t) + defer cleanup() + + // Create parent task + parent := &Task{Title: "Parent Task", Status: StatusProcessing} + if err := db.CreateTask(parent); err != nil { + t.Fatalf("Failed to create parent: %v", err) + } + + // Create child tasks + child1 := &Task{Title: "Child 1", Status: StatusQueued, ParentID: parent.ID} + child2 := &Task{Title: "Child 2", Status: StatusQueued, ParentID: parent.ID} + child3 := &Task{Title: "Child 3", Status: StatusQueued, ParentID: parent.ID} + if err := db.CreateTask(child1); err != nil { + t.Fatalf("Failed to create child1: %v", err) + } + if err := db.CreateTask(child2); err != nil { + t.Fatalf("Failed to create child2: %v", err) + } + if err := db.CreateTask(child3); err != nil { + t.Fatalf("Failed to create child3: %v", err) + } + + // Create unrelated task (should not appear) + unrelated := &Task{Title: "Unrelated", Status: StatusBacklog} + if err := db.CreateTask(unrelated); err != nil { + t.Fatalf("Failed to create unrelated: %v", err) + } + + // Get children + children, err := db.GetChildTasks(parent.ID) + if err != nil { + t.Fatalf("Failed to get child tasks: %v", err) + } + + if len(children) != 3 { + t.Errorf("Expected 3 children, got %d", len(children)) + } + + // Verify they're in creation order + if children[0].Title != "Child 1" { + t.Errorf("Expected first child to be 'Child 1', got %q", children[0].Title) + } + if children[2].Title != "Child 3" { + t.Errorf("Expected last child to be 'Child 3', got %q", children[2].Title) + } + + // Verify parent has no children fetched via wrong ID + noChildren, err := db.GetChildTasks(unrelated.ID) + if err != nil { + t.Fatalf("Failed to get child tasks for unrelated: %v", err) + } + if len(noChildren) != 0 { + t.Errorf("Expected 0 children for unrelated task, got %d", len(noChildren)) + } +} + +func TestGetTeamStatus(t *testing.T) { + db, cleanup := setupTeamsTestDB(t) + defer cleanup() + + // Create parent task + parent := &Task{Title: "Parent Task", Status: StatusProcessing} + if err := db.CreateTask(parent); err != nil { + t.Fatalf("Failed to create parent: %v", err) + } + + // Create children with different statuses + tasks := []*Task{ + {Title: "Queued 1", Status: StatusQueued, ParentID: parent.ID}, + {Title: "Queued 2", Status: StatusQueued, ParentID: parent.ID}, + {Title: "Processing", Status: StatusProcessing, ParentID: parent.ID}, + {Title: "Done 1", Status: StatusDone, ParentID: parent.ID}, + {Title: "Done 2", Status: StatusDone, ParentID: parent.ID}, + } + for _, task := range tasks { + if err := db.CreateTask(task); err != nil { + t.Fatalf("Failed to create task %q: %v", task.Title, err) + } + } + + status, err := db.GetTeamStatus(parent.ID) + if err != nil { + t.Fatalf("Failed to get team status: %v", err) + } + + if status.Total != 5 { + t.Errorf("Expected total=5, got %d", status.Total) + } + if status.Queued != 2 { + t.Errorf("Expected queued=2, got %d", status.Queued) + } + if status.Processing != 1 { + t.Errorf("Expected processing=1, got %d", status.Processing) + } + if status.Done != 2 { + t.Errorf("Expected done=2, got %d", status.Done) + } + if status.IsComplete() { + t.Error("Expected IsComplete to be false") + } +} + +func TestTeamStatusComplete(t *testing.T) { + db, cleanup := setupTeamsTestDB(t) + defer cleanup() + + parent := &Task{Title: "Parent", Status: StatusProcessing} + if err := db.CreateTask(parent); err != nil { + t.Fatalf("Failed to create parent: %v", err) + } + + // All children done + for i := 0; i < 3; i++ { + child := &Task{Title: "Done child", Status: StatusDone, ParentID: parent.ID} + if err := db.CreateTask(child); err != nil { + t.Fatalf("Failed to create child: %v", err) + } + } + + status, err := db.GetTeamStatus(parent.ID) + if err != nil { + t.Fatalf("Failed to get team status: %v", err) + } + + if !status.IsComplete() { + t.Error("Expected IsComplete to be true when all children are done") + } + if status.ActiveCount() != 0 { + t.Errorf("Expected ActiveCount=0, got %d", status.ActiveCount()) + } +} + +func TestTeamStatusEmpty(t *testing.T) { + db, cleanup := setupTeamsTestDB(t) + defer cleanup() + + parent := &Task{Title: "No Team", Status: StatusBacklog} + if err := db.CreateTask(parent); err != nil { + t.Fatalf("Failed to create parent: %v", err) + } + + status, err := db.GetTeamStatus(parent.ID) + if err != nil { + t.Fatalf("Failed to get team status: %v", err) + } + + if status.Total != 0 { + t.Errorf("Expected total=0, got %d", status.Total) + } + if status.IsComplete() { + t.Error("Expected IsComplete to be false for empty team") + } +} + +func TestHasChildTasks(t *testing.T) { + db, cleanup := setupTeamsTestDB(t) + defer cleanup() + + parent := &Task{Title: "Parent", Status: StatusBacklog} + if err := db.CreateTask(parent); err != nil { + t.Fatalf("Failed to create parent: %v", err) + } + + // No children yet + has, err := db.HasChildTasks(parent.ID) + if err != nil { + t.Fatalf("Failed to check child tasks: %v", err) + } + if has { + t.Error("Expected no children initially") + } + + // Add a child + child := &Task{Title: "Child", Status: StatusQueued, ParentID: parent.ID} + if err := db.CreateTask(child); err != nil { + t.Fatalf("Failed to create child: %v", err) + } + + has, err = db.HasChildTasks(parent.ID) + if err != nil { + t.Fatalf("Failed to check child tasks: %v", err) + } + if !has { + t.Error("Expected child tasks to exist") + } +} + +func TestGetTeamStatusMap(t *testing.T) { + db, cleanup := setupTeamsTestDB(t) + defer cleanup() + + // Create two parent tasks + parent1 := &Task{Title: "Parent 1", Status: StatusProcessing} + parent2 := &Task{Title: "Parent 2", Status: StatusProcessing} + if err := db.CreateTask(parent1); err != nil { + t.Fatalf("Failed to create parent1: %v", err) + } + if err := db.CreateTask(parent2); err != nil { + t.Fatalf("Failed to create parent2: %v", err) + } + + // Children for parent1 + db.CreateTask(&Task{Title: "P1 Child 1", Status: StatusDone, ParentID: parent1.ID}) + db.CreateTask(&Task{Title: "P1 Child 2", Status: StatusQueued, ParentID: parent1.ID}) + + // Children for parent2 + db.CreateTask(&Task{Title: "P2 Child 1", Status: StatusDone, ParentID: parent2.ID}) + db.CreateTask(&Task{Title: "P2 Child 2", Status: StatusDone, ParentID: parent2.ID}) + db.CreateTask(&Task{Title: "P2 Child 3", Status: StatusDone, ParentID: parent2.ID}) + + statusMap, err := db.GetTeamStatusMap() + if err != nil { + t.Fatalf("Failed to get team status map: %v", err) + } + + // Check parent1 + s1, ok := statusMap[parent1.ID] + if !ok { + t.Fatal("Expected parent1 in status map") + } + if s1.Total != 2 { + t.Errorf("Parent1: expected total=2, got %d", s1.Total) + } + if s1.Done != 1 { + t.Errorf("Parent1: expected done=1, got %d", s1.Done) + } + if s1.IsComplete() { + t.Error("Parent1: expected IsComplete=false") + } + + // Check parent2 + s2, ok := statusMap[parent2.ID] + if !ok { + t.Fatal("Expected parent2 in status map") + } + if s2.Total != 3 { + t.Errorf("Parent2: expected total=3, got %d", s2.Total) + } + if !s2.IsComplete() { + t.Error("Parent2: expected IsComplete=true") + } +} + +func TestParentIDInTaskCRUD(t *testing.T) { + db, cleanup := setupTeamsTestDB(t) + defer cleanup() + + // Create parent + parent := &Task{Title: "Parent", Status: StatusBacklog} + if err := db.CreateTask(parent); err != nil { + t.Fatalf("Failed to create parent: %v", err) + } + + // Create child with parent_id + child := &Task{Title: "Child", Status: StatusQueued, ParentID: parent.ID} + if err := db.CreateTask(child); err != nil { + t.Fatalf("Failed to create child: %v", err) + } + + // Fetch child and verify parent_id + fetched, err := db.GetTask(child.ID) + if err != nil { + t.Fatalf("Failed to get child: %v", err) + } + if fetched.ParentID != parent.ID { + t.Errorf("Expected ParentID=%d, got %d", parent.ID, fetched.ParentID) + } + + // Fetch parent and verify no parent_id + fetchedParent, err := db.GetTask(parent.ID) + if err != nil { + t.Fatalf("Failed to get parent: %v", err) + } + if fetchedParent.ParentID != 0 { + t.Errorf("Expected parent ParentID=0, got %d", fetchedParent.ParentID) + } +} diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 2f54b4e9..bc78fda8 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -280,6 +280,54 @@ func (s *Server) handleRequest(req *jsonRPCRequest) { "required": []string{"context"}, }, }, + { + Name: "taskyou_delegate_task", + Description: "Delegate a subtask to another agent. Creates a child task linked to the current task, automatically queues it for execution, and returns immediately. Use this to parallelize work across multiple agents. Each delegated task runs in its own isolated worktree. Use taskyou_get_team_status to monitor progress and taskyou_wait_for_task to collect results.", + InputSchema: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "title": map[string]interface{}{ + "type": "string", + "description": "Title of the subtask", + }, + "body": map[string]interface{}{ + "type": "string", + "description": "Detailed instructions for the subtask agent", + }, + "type": map[string]interface{}{ + "type": "string", + "description": "Task type (code, writing, thinking). Defaults to current task's type.", + }, + }, + "required": []string{"title"}, + }, + }, + { + Name: "taskyou_get_team_status", + Description: "Get the status of all subtasks delegated by the current task. Returns a summary showing how many subtasks are queued, processing, blocked, or done, plus details of each subtask.", + InputSchema: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{}, + }, + }, + { + Name: "taskyou_wait_for_task", + Description: "Wait for a delegated subtask to complete. Polls the task status until it reaches 'done' or 'blocked' state, then returns the task's summary and final status. Use this after taskyou_delegate_task to collect results from a specific subtask.", + InputSchema: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "task_id": map[string]interface{}{ + "type": "integer", + "description": "The ID of the subtask to wait for", + }, + "timeout_seconds": map[string]interface{}{ + "type": "integer", + "description": "Maximum seconds to wait (default: 300, max: 1800). Returns current status if timeout is reached.", + }, + }, + "required": []string{"task_id"}, + }, + }, }, }) @@ -683,6 +731,209 @@ This saves future tasks from re-exploring the codebase.`}, }, }) + case "taskyou_delegate_task": + title, _ := params.Arguments["title"].(string) + if title == "" { + s.sendError(id, -32602, "title is required") + return + } + body, _ := params.Arguments["body"].(string) + taskType, _ := params.Arguments["type"].(string) + + // Get current task for context + currentTask, err := s.db.GetTask(s.taskID) + if err != nil || currentTask == nil { + s.sendError(id, -32603, "Failed to get current task") + return + } + + // Default type to current task's type + if taskType == "" { + taskType = currentTask.Type + } + + newTask := &db.Task{ + Title: title, + Body: body, + Project: currentTask.Project, + Type: taskType, + Status: db.StatusQueued, // Auto-queue for immediate execution + Executor: currentTask.Executor, + ParentID: s.taskID, + } + + if err := s.db.CreateTask(newTask); err != nil { + s.sendError(id, -32603, fmt.Sprintf("Failed to create subtask: %v", err)) + return + } + + s.db.AppendTaskLog(s.taskID, "system", fmt.Sprintf("Delegated subtask #%d: %s", newTask.ID, title)) + + s.sendResult(id, toolCallResult{ + Content: []contentBlock{ + {Type: "text", Text: fmt.Sprintf("Delegated subtask #%d: %s\nStatus: queued (will start executing shortly)\n\nUse taskyou_get_team_status to monitor progress or taskyou_wait_for_task with task_id=%d to wait for completion.", newTask.ID, title, newTask.ID)}, + }, + }) + + case "taskyou_get_team_status": + // Get team status + teamStatus, err := s.db.GetTeamStatus(s.taskID) + if err != nil { + s.sendError(id, -32603, fmt.Sprintf("Failed to get team status: %v", err)) + return + } + + if teamStatus.Total == 0 { + s.sendResult(id, toolCallResult{ + Content: []contentBlock{ + {Type: "text", Text: "No subtasks delegated yet. Use taskyou_delegate_task to create subtasks for your team."}, + }, + }) + return + } + + // Get individual child tasks for details + children, err := s.db.GetChildTasks(s.taskID) + if err != nil { + s.sendError(id, -32603, fmt.Sprintf("Failed to get child tasks: %v", err)) + return + } + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("## Team Status: %d/%d complete\n\n", teamStatus.Done, teamStatus.Total)) + + // Progress bar + if teamStatus.Total > 0 { + pct := teamStatus.Done * 100 / teamStatus.Total + filled := pct / 5 // 20 chars wide + empty := 20 - filled + bar := strings.Repeat("█", filled) + strings.Repeat("░", empty) + sb.WriteString(fmt.Sprintf("[%s] %d%%\n\n", bar, pct)) + } + + // Status breakdown + if teamStatus.Processing > 0 { + sb.WriteString(fmt.Sprintf("- **Processing:** %d\n", teamStatus.Processing)) + } + if teamStatus.Queued > 0 { + sb.WriteString(fmt.Sprintf("- **Queued:** %d\n", teamStatus.Queued)) + } + if teamStatus.Blocked > 0 { + sb.WriteString(fmt.Sprintf("- **Blocked:** %d\n", teamStatus.Blocked)) + } + if teamStatus.Backlog > 0 { + sb.WriteString(fmt.Sprintf("- **Backlog:** %d\n", teamStatus.Backlog)) + } + if teamStatus.Done > 0 { + sb.WriteString(fmt.Sprintf("- **Done:** %d\n", teamStatus.Done)) + } + + sb.WriteString("\n## Subtasks\n\n") + for _, child := range children { + statusIcon := "⏳" + switch child.Status { + case db.StatusProcessing: + statusIcon = "⚡" + case db.StatusQueued: + statusIcon = "📋" + case db.StatusBlocked: + statusIcon = "🔒" + case db.StatusDone: + statusIcon = "✅" + case db.StatusBacklog: + statusIcon = "📝" + } + sb.WriteString(fmt.Sprintf("%s **#%d** %s — %s", statusIcon, child.ID, child.Title, child.Status)) + if child.Summary != "" { + sb.WriteString(fmt.Sprintf("\n Summary: %s", child.Summary)) + } + sb.WriteString("\n") + } + + s.sendResult(id, toolCallResult{ + Content: []contentBlock{ + {Type: "text", Text: sb.String()}, + }, + }) + + case "taskyou_wait_for_task": + taskIDFloat, ok := params.Arguments["task_id"].(float64) + if !ok { + s.sendError(id, -32602, "task_id is required") + return + } + targetTaskID := int64(taskIDFloat) + + timeoutSeconds := 300 + if t, ok := params.Arguments["timeout_seconds"].(float64); ok { + timeoutSeconds = int(t) + if timeoutSeconds > 1800 { + timeoutSeconds = 1800 + } + if timeoutSeconds < 5 { + timeoutSeconds = 5 + } + } + + // Verify the target task is a child of the current task + targetTask, err := s.db.GetTask(targetTaskID) + if err != nil || targetTask == nil { + s.sendError(id, -32602, fmt.Sprintf("Task #%d not found", targetTaskID)) + return + } + if targetTask.ParentID != s.taskID { + s.sendError(id, -32602, fmt.Sprintf("Task #%d is not a subtask of the current task", targetTaskID)) + return + } + + // Poll until done, blocked, or timeout + deadline := time.Now().Add(time.Duration(timeoutSeconds) * time.Second) + for time.Now().Before(deadline) { + task, err := s.db.GetTask(targetTaskID) + if err != nil || task == nil { + s.sendError(id, -32603, fmt.Sprintf("Failed to check task #%d: %v", targetTaskID, err)) + return + } + + if task.Status == db.StatusDone || task.Status == db.StatusBlocked { + var sb strings.Builder + sb.WriteString(fmt.Sprintf("## Task #%d: %s\n\n", task.ID, task.Title)) + sb.WriteString(fmt.Sprintf("**Status:** %s\n", task.Status)) + if task.PRURL != "" { + sb.WriteString(fmt.Sprintf("**PR:** %s\n", task.PRURL)) + } + if task.Summary != "" { + sb.WriteString(fmt.Sprintf("\n**Summary:** %s\n", task.Summary)) + } + if task.Status == db.StatusBlocked { + if question, err := s.db.GetLastQuestion(task.ID); err == nil && question != "" { + sb.WriteString(fmt.Sprintf("\n**Needs input:** %s\n", question)) + } + } + + s.sendResult(id, toolCallResult{ + Content: []contentBlock{ + {Type: "text", Text: sb.String()}, + }, + }) + return + } + + time.Sleep(3 * time.Second) + } + + // Timeout reached - return current status + task, _ := s.db.GetTask(targetTaskID) + statusText := "unknown" + if task != nil { + statusText = task.Status + } + s.sendResult(id, toolCallResult{ + Content: []contentBlock{ + {Type: "text", Text: fmt.Sprintf("Timeout waiting for task #%d. Current status: %s. Use taskyou_wait_for_task again to continue waiting, or taskyou_get_team_status to check all subtasks.", targetTaskID, statusText)}, + }, + }) + default: s.sendError(id, -32602, fmt.Sprintf("Unknown tool: %s", params.Name)) } diff --git a/internal/ui/app.go b/internal/ui/app.go index 8cbd29bf..2494bb8d 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -755,6 +755,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.kanban.SetRunningProcesses(running) m.kanban.SetTasksNeedingInput(m.tasksNeedingInput) m.kanban.SetBlockedByDeps(msg.blockedByDeps) + m.kanban.SetTeamStatuses(msg.teamStatuses) // Trigger initial PR refresh after first task load (subsequent refreshes via prRefreshTick) if !m.initialPRRefreshDone { @@ -2934,8 +2935,9 @@ func (m *AppModel) updateCommandPalette(msg tea.Msg) (tea.Model, tea.Cmd) { type tasksLoadedMsg struct { tasks []*db.Task err error - hiddenDoneCount int // Number of done tasks not shown in kanban (older ones) - blockedByDeps map[int64]int // Tasks blocked by dependencies (task ID -> open blocker count) + hiddenDoneCount int // Number of done tasks not shown in kanban (older ones) + blockedByDeps map[int64]int // Tasks blocked by dependencies (task ID -> open blocker count) + teamStatuses map[int64]*db.TeamStatus // Parent task ID -> team status (for team indicators) } type taskLoadedMsg struct { @@ -3052,9 +3054,12 @@ func (m *AppModel) loadTasks() tea.Cmd { } } + // Load team statuses for parent tasks (agent teams) + teamStatuses, _ := m.db.GetTeamStatusMap() + // Note: PR/merge status is now checked via batch refresh (prRefreshTick) // to avoid spawning processes for every task on every tick - return tasksLoadedMsg{tasks: tasks, err: err, hiddenDoneCount: hiddenDone, blockedByDeps: blockedByDeps} + return tasksLoadedMsg{tasks: tasks, err: err, hiddenDoneCount: hiddenDone, blockedByDeps: blockedByDeps, teamStatuses: teamStatuses} } } diff --git a/internal/ui/kanban.go b/internal/ui/kanban.go index f0a325b8..06245366 100644 --- a/internal/ui/kanban.go +++ b/internal/ui/kanban.go @@ -51,6 +51,7 @@ type KanbanBoard struct { runningProcesses map[int64]bool // Tasks with running shell processes tasksNeedingInput map[int64]bool // Tasks waiting for user input (active input notification) blockedByDeps map[int64]int // Tasks blocked by dependencies (task ID -> open blocker count) + teamStatuses map[int64]*db.TeamStatus // Parent task ID -> team status (for team indicators) hiddenDoneCount int // Number of done tasks not shown (older ones) originColumn int // Column where detail view navigation started (-1 = not set) } @@ -185,6 +186,19 @@ func (k *KanbanBoard) IsBlockedByDeps(taskID int64) bool { return k.GetOpenBlockerCount(taskID) > 0 } +// SetTeamStatuses updates the map of parent task IDs to their team statuses. +func (k *KanbanBoard) SetTeamStatuses(teamStatuses map[int64]*db.TeamStatus) { + k.teamStatuses = teamStatuses +} + +// GetTeamStatus returns the team status for a parent task, or nil if it has no team. +func (k *KanbanBoard) GetTeamStatus(taskID int64) *db.TeamStatus { + if k.teamStatuses == nil { + return nil + } + return k.teamStatuses[taskID] +} + // SetOriginColumn sets the origin column for detail view navigation. // This preserves the column context even if the task moves to a different column. func (k *KanbanBoard) SetOriginColumn() { @@ -1039,6 +1053,20 @@ func (k *KanbanBoard) renderTaskCard(task *db.Task, width int, isSelected bool, indicators = append(indicators, pinStyle.Render(IconPin())) } } + // Team indicator for parent tasks (shows progress like "👥 3/5") + if ts := k.GetTeamStatus(task.ID); ts != nil { + teamText := fmt.Sprintf("👥%d/%d", ts.Done, ts.Total) + if isSelected { + indicators = append(indicators, teamText) + } else { + teamColor := lipgloss.Color("#61AFEF") // Blue + if ts.IsComplete() { + teamColor = lipgloss.Color("#98C379") // Green when all done + } + teamStyle := lipgloss.NewStyle().Foreground(teamColor) + indicators = append(indicators, teamStyle.Render(teamText)) + } + } // Keyboard shortcut hint (shown at the end) if shortcutHint != "" { if isSelected { @@ -1049,6 +1077,18 @@ func (k *KanbanBoard) renderTaskCard(task *db.Task, width int, isSelected bool, } } + // Child task indicator (shows parent link) + if task.ParentID > 0 { + parentText := fmt.Sprintf("↑#%d", task.ParentID) + if isSelected { + b.WriteString(" " + parentText) + } else { + parentStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#61AFEF")) // Blue + b.WriteString(" ") + b.WriteString(parentStyle.Render(parentText)) + } + } + // Dependency blocker indicator (lock icon) blockerCount := k.GetOpenBlockerCount(task.ID) if blockerCount > 0 {