Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions internal/db/dependencies.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ?
Expand All @@ -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 = ?
Expand Down Expand Up @@ -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)
Expand Down
5 changes: 5 additions & 0 deletions internal/db/sqlite.go
Original file line number Diff line number Diff line change
Expand Up @@ -251,13 +251,18 @@ 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 {
// Ignore "duplicate column" errors for idempotent migrations
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
Expand Down
36 changes: 26 additions & 10 deletions internal/db/tasks.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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{}{}
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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)
}
Expand All @@ -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
Expand All @@ -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)
}
Expand Down
167 changes: 167 additions & 0 deletions internal/db/teams.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading