diff --git a/internal/db/executor_session_test.go b/internal/db/executor_session_test.go new file mode 100644 index 0000000..30b28c9 --- /dev/null +++ b/internal/db/executor_session_test.go @@ -0,0 +1,223 @@ +package db + +import ( + "os" + "path/filepath" + "testing" +) + +func TestExecutorSessionCRUD(t *testing.T) { + // Create temp database + tmpDir, err := os.MkdirTemp("", "test-executor-session-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + dbPath := filepath.Join(tmpDir, "test.db") + database, err := Open(dbPath) + if err != nil { + t.Fatal(err) + } + defer database.Close() + + // Create a task first + task := &Task{ + Title: "Test task", + Status: StatusBacklog, + Project: "personal", + Executor: ExecutorClaude, + } + if err := database.CreateTask(task); err != nil { + t.Fatalf("CreateTask failed: %v", err) + } + + // Test CreateExecutorSession + session := &ExecutorSession{ + TaskID: task.ID, + Executor: ExecutorClaude, + Status: SessionStatusPending, + } + if err := database.CreateExecutorSession(session); err != nil { + t.Fatalf("CreateExecutorSession failed: %v", err) + } + if session.ID == 0 { + t.Error("Expected session ID to be set") + } + + // Test GetExecutorSession + retrieved, err := database.GetExecutorSession(session.ID) + if err != nil { + t.Fatalf("GetExecutorSession failed: %v", err) + } + if retrieved == nil { + t.Fatal("Expected session to be retrieved") + } + if retrieved.TaskID != task.ID { + t.Errorf("Expected TaskID %d, got %d", task.ID, retrieved.TaskID) + } + if retrieved.Executor != ExecutorClaude { + t.Errorf("Expected Executor %s, got %s", ExecutorClaude, retrieved.Executor) + } + + // Test UpdateExecutorSession + session.Status = SessionStatusActive + session.DaemonSession = "task-daemon-123" + if err := database.UpdateExecutorSession(session); err != nil { + t.Fatalf("UpdateExecutorSession failed: %v", err) + } + + updated, _ := database.GetExecutorSession(session.ID) + if updated.Status != SessionStatusActive { + t.Errorf("Expected Status %s, got %s", SessionStatusActive, updated.Status) + } + if updated.DaemonSession != "task-daemon-123" { + t.Errorf("Expected DaemonSession %s, got %s", "task-daemon-123", updated.DaemonSession) + } + + // Test DeleteExecutorSession + if err := database.DeleteExecutorSession(session.ID); err != nil { + t.Fatalf("DeleteExecutorSession failed: %v", err) + } + deleted, _ := database.GetExecutorSession(session.ID) + if deleted != nil { + t.Error("Expected session to be deleted") + } +} + +func TestMultipleExecutorSessions(t *testing.T) { + // Create temp database + tmpDir, err := os.MkdirTemp("", "test-multi-session-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + dbPath := filepath.Join(tmpDir, "test.db") + database, err := Open(dbPath) + if err != nil { + t.Fatal(err) + } + defer database.Close() + + // Create a task + task := &Task{ + Title: "Test task", + Status: StatusBacklog, + Project: "personal", + Executor: ExecutorClaude, + } + if err := database.CreateTask(task); err != nil { + t.Fatalf("CreateTask failed: %v", err) + } + + // Create multiple executor sessions + sessions := []*ExecutorSession{ + {TaskID: task.ID, Executor: ExecutorClaude, Status: SessionStatusCompleted}, + {TaskID: task.ID, Executor: ExecutorCodex, Status: SessionStatusActive}, + {TaskID: task.ID, Executor: ExecutorGemini, Status: SessionStatusPending}, + } + + for _, s := range sessions { + if err := database.CreateExecutorSession(s); err != nil { + t.Fatalf("CreateExecutorSession failed: %v", err) + } + } + + // Test GetExecutorSessionsForTask + retrieved, err := database.GetExecutorSessionsForTask(task.ID) + if err != nil { + t.Fatalf("GetExecutorSessionsForTask failed: %v", err) + } + if len(retrieved) != 3 { + t.Errorf("Expected 3 sessions, got %d", len(retrieved)) + } + + // Test GetActiveExecutorSession + active, err := database.GetActiveExecutorSession(task.ID) + if err != nil { + t.Fatalf("GetActiveExecutorSession failed: %v", err) + } + if active == nil { + t.Fatal("Expected active session") + } + if active.Executor != ExecutorCodex { + t.Errorf("Expected active executor %s, got %s", ExecutorCodex, active.Executor) + } + + // Test SetActiveExecutorSession + if err := database.SetActiveExecutorSession(task.ID, sessions[2].ID); err != nil { + t.Fatalf("SetActiveExecutorSession failed: %v", err) + } + updatedTask, _ := database.GetTask(task.ID) + if updatedTask.ActiveSessionID != sessions[2].ID { + t.Errorf("Expected ActiveSessionID %d, got %d", sessions[2].ID, updatedTask.ActiveSessionID) + } +} + +func TestTaskLogsWithSession(t *testing.T) { + // Create temp database + tmpDir, err := os.MkdirTemp("", "test-logs-session-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + dbPath := filepath.Join(tmpDir, "test.db") + database, err := Open(dbPath) + if err != nil { + t.Fatal(err) + } + defer database.Close() + + // Create a task + task := &Task{ + Title: "Test task", + Status: StatusBacklog, + Project: "personal", + Executor: ExecutorClaude, + } + if err := database.CreateTask(task); err != nil { + t.Fatalf("CreateTask failed: %v", err) + } + + // Create executor session + session := &ExecutorSession{ + TaskID: task.ID, + Executor: ExecutorClaude, + Status: SessionStatusActive, + } + if err := database.CreateExecutorSession(session); err != nil { + t.Fatalf("CreateExecutorSession failed: %v", err) + } + + // Add logs with session ID + if err := database.AppendTaskLogForSession(task.ID, session.ID, "output", "Log with session"); err != nil { + t.Fatalf("AppendTaskLogForSession failed: %v", err) + } + + // Add legacy log without session ID + if err := database.AppendTaskLog(task.ID, "output", "Legacy log"); err != nil { + t.Fatalf("AppendTaskLog failed: %v", err) + } + + // Test GetTaskLogsForSession - should return both session-specific and legacy logs + logs, err := database.GetTaskLogsForSession(task.ID, session.ID, 100) + if err != nil { + t.Fatalf("GetTaskLogsForSession failed: %v", err) + } + if len(logs) != 2 { + t.Errorf("Expected 2 logs, got %d", len(logs)) + } + + // Verify session ID is set on the session-specific log + sessionLogFound := false + for _, l := range logs { + if l.Content == "Log with session" && l.SessionID == session.ID { + sessionLogFound = true + } + } + if !sessionLogFound { + t.Error("Expected to find log with session ID") + } +} diff --git a/internal/db/sqlite.go b/internal/db/sqlite.go index 9e1b07c..25581ba 100644 --- a/internal/db/sqlite.go +++ b/internal/db/sqlite.go @@ -178,6 +178,25 @@ func (db *DB) migrate() error { )`, `CREATE INDEX IF NOT EXISTS idx_task_compaction_summaries_task_id ON task_compaction_summaries(task_id)`, + + // Executor sessions table - tracks multiple executor sessions per task + `CREATE TABLE IF NOT EXISTS task_executor_sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + executor TEXT NOT NULL DEFAULT 'claude', + session_id TEXT DEFAULT '', + daemon_session TEXT DEFAULT '', + tmux_window_id TEXT DEFAULT '', + claude_pane_id TEXT DEFAULT '', + shell_pane_id TEXT DEFAULT '', + status TEXT DEFAULT 'pending', + dangerous_mode INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + started_at DATETIME, + completed_at DATETIME + )`, + + `CREATE INDEX IF NOT EXISTS idx_task_executor_sessions_task_id ON task_executor_sessions(task_id)`, } for _, m := range migrations { @@ -226,6 +245,10 @@ func (db *DB) migrate() error { `ALTER TABLE tasks ADD COLUMN shell_pane_id TEXT DEFAULT ''`, // tmux pane ID for shell pane (e.g., "%1235") // Auto-generated project context for caching exploration results `ALTER TABLE projects ADD COLUMN context TEXT DEFAULT ''`, // Auto-generated project context (codebase summary, patterns, etc.) + // Active executor session tracking - references the currently active session + `ALTER TABLE tasks ADD COLUMN active_session_id INTEGER DEFAULT 0`, // FK to task_executor_sessions.id (0 = none/use legacy) + // Session ID for task logs - associates logs with a specific executor session + `ALTER TABLE task_logs ADD COLUMN session_id INTEGER DEFAULT 0`, // FK to task_executor_sessions.id (0 = legacy/all sessions) } for _, m := range alterMigrations { diff --git a/internal/db/tasks.go b/internal/db/tasks.go index 8a95752..598cdaf 100644 --- a/internal/db/tasks.go +++ b/internal/db/tasks.go @@ -38,6 +38,8 @@ type Task struct { CompletedAt *LocalTime // Distillation tracking LastDistilledAt *LocalTime // When task was last distilled for learnings + // Multi-executor support + ActiveSessionID int64 // Currently active executor session (FK to task_executor_sessions.id, 0 = use legacy fields) } // Task statuses @@ -92,6 +94,32 @@ type TaskType struct { CreatedAt LocalTime } +// ExecutorSession represents an executor session for a task. +// Multiple sessions can exist per task, allowing different executors to work on the same task. +type ExecutorSession struct { + ID int64 + TaskID int64 + Executor string // "claude", "codex", "gemini", "openclaw" + SessionID string // Executor-specific session ID (e.g., Claude session ID) + DaemonSession string // tmux daemon session name + TmuxWindowID string // tmux window ID + ClaudePaneID string // tmux pane ID for executor + ShellPaneID string // tmux pane ID for shell + Status string // "pending", "active", "completed", "failed" + DangerousMode bool // Whether running in dangerous mode + CreatedAt LocalTime + StartedAt *LocalTime + CompletedAt *LocalTime +} + +// ExecutorSession statuses +const ( + SessionStatusPending = "pending" // Session created but not started + SessionStatusActive = "active" // Session is currently running + SessionStatusCompleted = "completed" // Session finished successfully + SessionStatusFailed = "failed" // Session failed +) + // ErrProjectNotFound is returned when a task is created with a non-existent project. var ErrProjectNotFound = fmt.Errorf("project not found") @@ -159,7 +187,7 @@ func (db *DB) GetTask(id int64) (*Task, error) { COALESCE(pr_url, ''), COALESCE(pr_number, 0), COALESCE(dangerous_mode, 0), COALESCE(pinned, 0), COALESCE(tags, ''), COALESCE(summary, ''), created_at, updated_at, started_at, completed_at, - last_distilled_at + last_distilled_at, COALESCE(active_session_id, 0) FROM tasks WHERE id = ? `, id).Scan( &t.ID, &t.Title, &t.Body, &t.Status, &t.Type, &t.Project, &t.Executor, @@ -168,7 +196,7 @@ func (db *DB) GetTask(id int64) (*Task, error) { &t.PRURL, &t.PRNumber, &t.DangerousMode, &t.Pinned, &t.Tags, &t.Summary, &t.CreatedAt, &t.UpdatedAt, &t.StartedAt, &t.CompletedAt, - &t.LastDistilledAt, + &t.LastDistilledAt, &t.ActiveSessionID, ) if err == sql.ErrNoRows { return nil, nil @@ -199,7 +227,7 @@ func (db *DB) ListTasks(opts ListTasksOptions) ([]*Task, error) { COALESCE(pr_url, ''), COALESCE(pr_number, 0), COALESCE(dangerous_mode, 0), COALESCE(pinned, 0), COALESCE(tags, ''), COALESCE(summary, ''), created_at, updated_at, started_at, completed_at, - last_distilled_at + last_distilled_at, COALESCE(active_session_id, 0) FROM tasks WHERE 1=1 ` args := []interface{}{} @@ -251,7 +279,7 @@ func (db *DB) ListTasks(opts ListTasksOptions) ([]*Task, error) { &t.PRURL, &t.PRNumber, &t.DangerousMode, &t.Pinned, &t.Tags, &t.Summary, &t.CreatedAt, &t.UpdatedAt, &t.StartedAt, &t.CompletedAt, - &t.LastDistilledAt, + &t.LastDistilledAt, &t.ActiveSessionID, ) if err != nil { return nil, fmt.Errorf("scan task: %w", err) @@ -274,7 +302,7 @@ func (db *DB) GetMostRecentlyCreatedTask() (*Task, error) { COALESCE(pr_url, ''), COALESCE(pr_number, 0), COALESCE(dangerous_mode, 0), COALESCE(pinned, 0), COALESCE(tags, ''), COALESCE(summary, ''), created_at, updated_at, started_at, completed_at, - last_distilled_at + last_distilled_at, COALESCE(active_session_id, 0) FROM tasks ORDER BY created_at DESC, id DESC LIMIT 1 @@ -285,7 +313,7 @@ func (db *DB) GetMostRecentlyCreatedTask() (*Task, error) { &t.PRURL, &t.PRNumber, &t.DangerousMode, &t.Pinned, &t.Tags, &t.Summary, &t.CreatedAt, &t.UpdatedAt, &t.StartedAt, &t.CompletedAt, - &t.LastDistilledAt, + &t.LastDistilledAt, &t.ActiveSessionID, ) if err == sql.ErrNoRows { return nil, nil @@ -312,7 +340,7 @@ func (db *DB) SearchTasks(query string, limit int) ([]*Task, error) { COALESCE(pr_url, ''), COALESCE(pr_number, 0), COALESCE(dangerous_mode, 0), COALESCE(pinned, 0), COALESCE(tags, ''), COALESCE(summary, ''), created_at, updated_at, started_at, completed_at, - last_distilled_at + last_distilled_at, COALESCE(active_session_id, 0) FROM tasks WHERE ( title LIKE ? COLLATE NOCASE @@ -342,7 +370,7 @@ func (db *DB) SearchTasks(query string, limit int) ([]*Task, error) { &t.PRURL, &t.PRNumber, &t.DangerousMode, &t.Pinned, &t.Tags, &t.Summary, &t.CreatedAt, &t.UpdatedAt, &t.StartedAt, &t.CompletedAt, - &t.LastDistilledAt, + &t.LastDistilledAt, &t.ActiveSessionID, ) if err != nil { return nil, fmt.Errorf("scan task: %w", err) @@ -571,7 +599,7 @@ func (db *DB) GetNextQueuedTask() (*Task, error) { COALESCE(pr_url, ''), COALESCE(pr_number, 0), COALESCE(dangerous_mode, 0), COALESCE(pinned, 0), COALESCE(tags, ''), COALESCE(summary, ''), created_at, updated_at, started_at, completed_at, - last_distilled_at + last_distilled_at, COALESCE(active_session_id, 0) FROM tasks WHERE status = ? ORDER BY created_at ASC @@ -583,7 +611,7 @@ func (db *DB) GetNextQueuedTask() (*Task, error) { &t.PRURL, &t.PRNumber, &t.DangerousMode, &t.Pinned, &t.Tags, &t.Summary, &t.CreatedAt, &t.UpdatedAt, &t.StartedAt, &t.CompletedAt, - &t.LastDistilledAt, + &t.LastDistilledAt, &t.ActiveSessionID, ) if err == sql.ErrNoRows { return nil, nil @@ -604,7 +632,7 @@ func (db *DB) GetQueuedTasks() ([]*Task, error) { COALESCE(pr_url, ''), COALESCE(pr_number, 0), COALESCE(dangerous_mode, 0), COALESCE(pinned, 0), COALESCE(tags, ''), COALESCE(summary, ''), created_at, updated_at, started_at, completed_at, - last_distilled_at + last_distilled_at, COALESCE(active_session_id, 0) FROM tasks WHERE status = ? ORDER BY created_at ASC @@ -624,7 +652,7 @@ func (db *DB) GetQueuedTasks() ([]*Task, error) { &t.PRURL, &t.PRNumber, &t.DangerousMode, &t.Pinned, &t.Tags, &t.Summary, &t.CreatedAt, &t.UpdatedAt, &t.StartedAt, &t.CompletedAt, - &t.LastDistilledAt, + &t.LastDistilledAt, &t.ActiveSessionID, ); err != nil { return nil, fmt.Errorf("scan task: %w", err) } @@ -644,7 +672,7 @@ func (db *DB) GetTasksWithBranches() ([]*Task, error) { COALESCE(pr_url, ''), COALESCE(pr_number, 0), COALESCE(dangerous_mode, 0), COALESCE(pinned, 0), COALESCE(tags, ''), COALESCE(summary, ''), created_at, updated_at, started_at, completed_at, - last_distilled_at + last_distilled_at, COALESCE(active_session_id, 0) FROM tasks WHERE branch_name != '' AND status NOT IN (?, ?) ORDER BY created_at DESC @@ -664,7 +692,7 @@ func (db *DB) GetTasksWithBranches() ([]*Task, error) { &t.PRURL, &t.PRNumber, &t.DangerousMode, &t.Pinned, &t.Tags, &t.Summary, &t.CreatedAt, &t.UpdatedAt, &t.StartedAt, &t.CompletedAt, - &t.LastDistilledAt, + &t.LastDistilledAt, &t.ActiveSessionID, ); err != nil { return nil, fmt.Errorf("scan task: %w", err) } @@ -677,6 +705,7 @@ func (db *DB) GetTasksWithBranches() ([]*Task, error) { type TaskLog struct { ID int64 TaskID int64 + SessionID int64 // Executor session ID (0 = legacy, before multi-session support) LineType string // "output", "tool", "error", "system" Content string CreatedAt LocalTime @@ -1441,3 +1470,180 @@ func (db *DB) GetTagsList() ([]string, error) { } return result, nil } + +// CreateExecutorSession creates a new executor session for a task. +func (db *DB) CreateExecutorSession(s *ExecutorSession) error { + result, err := db.Exec(` + INSERT INTO task_executor_sessions (task_id, executor, session_id, daemon_session, tmux_window_id, + claude_pane_id, shell_pane_id, status, dangerous_mode, started_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, s.TaskID, s.Executor, s.SessionID, s.DaemonSession, s.TmuxWindowID, + s.ClaudePaneID, s.ShellPaneID, s.Status, s.DangerousMode, s.StartedAt) + if err != nil { + return fmt.Errorf("insert executor session: %w", err) + } + id, err := result.LastInsertId() + if err != nil { + return fmt.Errorf("get last insert id: %w", err) + } + s.ID = id + return nil +} + +// GetExecutorSession retrieves an executor session by ID. +func (db *DB) GetExecutorSession(id int64) (*ExecutorSession, error) { + s := &ExecutorSession{} + err := db.QueryRow(` + SELECT id, task_id, executor, COALESCE(session_id, ''), COALESCE(daemon_session, ''), + COALESCE(tmux_window_id, ''), COALESCE(claude_pane_id, ''), COALESCE(shell_pane_id, ''), + status, COALESCE(dangerous_mode, 0), created_at, started_at, completed_at + FROM task_executor_sessions WHERE id = ? + `, id).Scan( + &s.ID, &s.TaskID, &s.Executor, &s.SessionID, &s.DaemonSession, + &s.TmuxWindowID, &s.ClaudePaneID, &s.ShellPaneID, + &s.Status, &s.DangerousMode, &s.CreatedAt, &s.StartedAt, &s.CompletedAt, + ) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("query executor session: %w", err) + } + return s, nil +} + +// GetExecutorSessionsForTask retrieves all executor sessions for a task. +func (db *DB) GetExecutorSessionsForTask(taskID int64) ([]*ExecutorSession, error) { + rows, err := db.Query(` + SELECT id, task_id, executor, COALESCE(session_id, ''), COALESCE(daemon_session, ''), + COALESCE(tmux_window_id, ''), COALESCE(claude_pane_id, ''), COALESCE(shell_pane_id, ''), + status, COALESCE(dangerous_mode, 0), created_at, started_at, completed_at + FROM task_executor_sessions WHERE task_id = ? + ORDER BY created_at ASC + `, taskID) + if err != nil { + return nil, fmt.Errorf("query executor sessions: %w", err) + } + defer rows.Close() + + var sessions []*ExecutorSession + for rows.Next() { + s := &ExecutorSession{} + if err := rows.Scan( + &s.ID, &s.TaskID, &s.Executor, &s.SessionID, &s.DaemonSession, + &s.TmuxWindowID, &s.ClaudePaneID, &s.ShellPaneID, + &s.Status, &s.DangerousMode, &s.CreatedAt, &s.StartedAt, &s.CompletedAt, + ); err != nil { + return nil, fmt.Errorf("scan executor session: %w", err) + } + sessions = append(sessions, s) + } + return sessions, nil +} + +// GetActiveExecutorSession retrieves the active executor session for a task. +func (db *DB) GetActiveExecutorSession(taskID int64) (*ExecutorSession, error) { + s := &ExecutorSession{} + err := db.QueryRow(` + SELECT id, task_id, executor, COALESCE(session_id, ''), COALESCE(daemon_session, ''), + COALESCE(tmux_window_id, ''), COALESCE(claude_pane_id, ''), COALESCE(shell_pane_id, ''), + status, COALESCE(dangerous_mode, 0), created_at, started_at, completed_at + FROM task_executor_sessions + WHERE task_id = ? AND status = ? + ORDER BY created_at DESC + LIMIT 1 + `, taskID, SessionStatusActive).Scan( + &s.ID, &s.TaskID, &s.Executor, &s.SessionID, &s.DaemonSession, + &s.TmuxWindowID, &s.ClaudePaneID, &s.ShellPaneID, + &s.Status, &s.DangerousMode, &s.CreatedAt, &s.StartedAt, &s.CompletedAt, + ) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("query active executor session: %w", err) + } + return s, nil +} + +// UpdateExecutorSession updates an executor session. +func (db *DB) UpdateExecutorSession(s *ExecutorSession) error { + _, err := db.Exec(` + UPDATE task_executor_sessions SET + executor = ?, session_id = ?, daemon_session = ?, tmux_window_id = ?, + claude_pane_id = ?, shell_pane_id = ?, status = ?, dangerous_mode = ?, + started_at = ?, completed_at = ? + WHERE id = ? + `, s.Executor, s.SessionID, s.DaemonSession, s.TmuxWindowID, + s.ClaudePaneID, s.ShellPaneID, s.Status, s.DangerousMode, + s.StartedAt, s.CompletedAt, s.ID) + if err != nil { + return fmt.Errorf("update executor session: %w", err) + } + return nil +} + +// SetActiveExecutorSession sets the active executor session for a task. +func (db *DB) SetActiveExecutorSession(taskID int64, sessionID int64) error { + _, err := db.Exec(` + UPDATE tasks SET active_session_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? + `, sessionID, taskID) + if err != nil { + return fmt.Errorf("set active executor session: %w", err) + } + return nil +} + +// DeleteExecutorSession deletes an executor session. +func (db *DB) DeleteExecutorSession(id int64) error { + _, err := db.Exec("DELETE FROM task_executor_sessions WHERE id = ?", id) + if err != nil { + return fmt.Errorf("delete executor session: %w", err) + } + return nil +} + +// AppendTaskLogForSession appends a log entry to a task for a specific session. +func (db *DB) AppendTaskLogForSession(taskID int64, sessionID int64, lineType, content string) error { + _, err := db.Exec(` + INSERT INTO task_logs (task_id, session_id, line_type, content) + VALUES (?, ?, ?, ?) + `, taskID, sessionID, lineType, content) + if err != nil { + return fmt.Errorf("insert task log for session: %w", err) + } + return nil +} + +// GetTaskLogsForSession retrieves logs for a task filtered by session. +// If sessionID is 0, returns all logs (legacy behavior). +// Otherwise, returns logs for the specific session plus any legacy logs (session_id = 0). +func (db *DB) GetTaskLogsForSession(taskID int64, sessionID int64, limit int) ([]*TaskLog, error) { + if limit <= 0 { + limit = 1000 + } + + rows, err := db.Query(` + SELECT id, task_id, COALESCE(session_id, 0), line_type, content, created_at + FROM task_logs + WHERE task_id = ? AND (session_id = ? OR session_id = 0 OR session_id IS NULL) + ORDER BY id DESC + LIMIT ? + `, taskID, sessionID, limit) + if err != nil { + return nil, fmt.Errorf("query task logs for session: %w", err) + } + defer rows.Close() + + var logs []*TaskLog + for rows.Next() { + l := &TaskLog{} + err := rows.Scan(&l.ID, &l.TaskID, &l.SessionID, &l.LineType, &l.Content, &l.CreatedAt) + if err != nil { + return nil, fmt.Errorf("scan task log: %w", err) + } + logs = append(logs, l) + } + + return logs, nil +} diff --git a/internal/executor/executor.go b/internal/executor/executor.go index f2567de..86cdd0e 100644 --- a/internal/executor/executor.go +++ b/internal/executor/executor.go @@ -818,6 +818,26 @@ func (e *Executor) executeTask(ctx context.Context, task *db.Task) { return } + // Create or get executor session for tracking + var session *db.ExecutorSession + if !isRetry { + // Create new executor session + now := db.LocalTime{Time: time.Now()} + session = &db.ExecutorSession{ + TaskID: task.ID, + Executor: executorName, + Status: db.SessionStatusActive, + DangerousMode: task.DangerousMode, + StartedAt: &now, + } + if err := e.db.CreateExecutorSession(session); err != nil { + e.logger.Error("Failed to create executor session", "error", err) + } else { + // Set as active session + e.db.SetActiveExecutorSession(task.ID, session.ID) + } + } + // Run the executor var result execResult if isRetry { @@ -889,6 +909,23 @@ func (e *Executor) executeTask(ctx context.Context, task *db.Task) { e.hooks.OnStatusChange(task, db.StatusBlocked, msg) } + // Update executor session status + if session != nil { + now := db.LocalTime{Time: time.Now()} + session.CompletedAt = &now + if result.Interrupted { + session.Status = db.SessionStatusFailed + } else if result.Success || currentStatus == db.StatusDone { + session.Status = db.SessionStatusCompleted + } else { + // Still active (blocked/needs input means the executor is still running) + session.Status = db.SessionStatusActive + } + if err := e.db.UpdateExecutorSession(session); err != nil { + e.logger.Error("Failed to update executor session", "error", err) + } + } + e.logger.Info("Task finished", "id", task.ID, "success", result.Success) } diff --git a/internal/ui/detail.go b/internal/ui/detail.go index 76eca41..6c97b5c 100644 --- a/internal/ui/detail.go +++ b/internal/ui/detail.go @@ -104,6 +104,10 @@ type DetailModel struct { // Shell pane visibility toggle shellPaneHidden bool // true when shell pane is collapsed to daemon + + // Multi-executor session support + sessions []*db.ExecutorSession // All sessions for this task + selectedSession int // Index of currently selected session (0-based) } // Message types for async pane loading @@ -207,6 +211,16 @@ func (m *DetailModel) Refresh() { m.task = task } + // Reload executor sessions + sessions, err := m.database.GetExecutorSessionsForTask(m.task.ID) + if err == nil { + m.sessions = sessions + // Ensure selectedSession is within bounds + if m.selectedSession >= len(m.sessions) { + m.selectedSession = 0 + } + } + // Check log count first to avoid loading all logs if unchanged logCount, err := m.database.GetTaskLogCount(m.task.ID) if err == nil && logCount != m.lastLogCount { @@ -287,6 +301,10 @@ func NewDetailModel(t *db.Task, database *db.DB, exec *executor.Executor, width, logs, _ := database.GetTaskLogs(t.ID, 100) m.logs = logs + // Load executor sessions + sessions, _ := database.GetExecutorSessionsForTask(t.ID) + m.sessions = sessions + m.initViewport() // Skip initial memory check - it's expensive (3 shell commands) @@ -489,6 +507,21 @@ func (m *DetailModel) Update(msg tea.Msg) (*DetailModel, tea.Cmd) { log.Info("panesRefreshMsg: refreshing panes for task %d", m.task.ID) // Re-start the async pane setup return m, m.startPanesAsync() + + case tea.KeyMsg: + // Handle session switching with < and > + switch msg.String() { + case "<", "shift+,": + if len(m.sessions) > 1 { + m.SelectPrevSession() + return m, nil + } + case ">", "shift+.": + if len(m.sessions) > 1 { + m.SelectNextSession() + return m, nil + } + } } // Pass all messages to viewport for scrolling support @@ -2019,6 +2052,7 @@ func (m *DetailModel) View() string { } header := m.renderHeader() + sessionTabs := m.renderSessionTabs() content := m.viewport.View() // Use dimmed border when unfocused @@ -2051,9 +2085,21 @@ func (m *DetailModel) View() string { } help := m.renderHelp() - boxContent := lipgloss.JoinVertical(lipgloss.Left, header, content) - if scrollIndicator != "" { - boxContent = lipgloss.JoinVertical(lipgloss.Left, header, content, scrollIndicator) + + // Build box content with optional session tabs + var boxContent string + if sessionTabs != "" { + if scrollIndicator != "" { + boxContent = lipgloss.JoinVertical(lipgloss.Left, header, sessionTabs, content, scrollIndicator) + } else { + boxContent = lipgloss.JoinVertical(lipgloss.Left, header, sessionTabs, content) + } + } else { + if scrollIndicator != "" { + boxContent = lipgloss.JoinVertical(lipgloss.Left, header, content, scrollIndicator) + } else { + boxContent = lipgloss.JoinVertical(lipgloss.Left, header, content) + } } // Build view parts @@ -2272,6 +2318,100 @@ func (m *DetailModel) renderHeader() string { return lipgloss.JoinVertical(lipgloss.Left, headerLayout, "") } +// renderSessionTabs renders the executor session tabs (if there are multiple sessions). +func (m *DetailModel) renderSessionTabs() string { + // Don't show tabs if there are no sessions or only one + if len(m.sessions) <= 1 { + return "" + } + + var tabs strings.Builder + + // Muted colors for unfocused state + dimmedBg := lipgloss.Color("#4B5563") + dimmedFg := lipgloss.Color("#9CA3AF") + + for i, session := range m.sessions { + isSelected := i == m.selectedSession + + // Build tab label + label := strings.ToUpper(session.Executor[:1]) + session.Executor[1:] + if session.Status == db.SessionStatusActive { + label += " ●" + } else if session.Status == db.SessionStatusCompleted { + label += " ✓" + } else if session.Status == db.SessionStatusFailed { + label += " ✗" + } + + var tabStyle lipgloss.Style + if isSelected { + if m.focused { + tabStyle = lipgloss.NewStyle(). + Padding(0, 1). + Background(ColorPrimary). + Foreground(lipgloss.Color("#FFFFFF")). + Bold(true) + } else { + tabStyle = lipgloss.NewStyle(). + Padding(0, 1). + Background(dimmedBg). + Foreground(dimmedFg). + Bold(true) + } + } else { + tabStyle = lipgloss.NewStyle(). + Padding(0, 1). + Foreground(lipgloss.Color("#6B7280")) + } + + if i > 0 { + tabs.WriteString(" ") + } + tabs.WriteString(tabStyle.Render(label)) + } + + // Add hint for switching tabs + hint := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#4B5563")). + Render(" (< > to switch)") + + return tabs.String() + hint +} + +// HasMultipleSessions returns true if the task has multiple executor sessions. +func (m *DetailModel) HasMultipleSessions() bool { + return len(m.sessions) > 1 +} + +// GetSelectedSession returns the currently selected executor session. +func (m *DetailModel) GetSelectedSession() *db.ExecutorSession { + if m.selectedSession >= 0 && m.selectedSession < len(m.sessions) { + return m.sessions[m.selectedSession] + } + return nil +} + +// SelectNextSession moves to the next executor session. +func (m *DetailModel) SelectNextSession() { + if len(m.sessions) > 1 { + m.selectedSession = (m.selectedSession + 1) % len(m.sessions) + if m.ready { + m.viewport.SetContent(m.renderContent()) + } + } +} + +// SelectPrevSession moves to the previous executor session. +func (m *DetailModel) SelectPrevSession() { + if len(m.sessions) > 1 { + m.selectedSession = (m.selectedSession - 1 + len(m.sessions)) % len(m.sessions) + if m.ready { + m.viewport.SetContent(m.renderContent()) + } + } +} + // getGlamourRenderer returns a cached Glamour renderer, creating it if needed. // Renderers are cached separately for focused and unfocused states. func (m *DetailModel) getGlamourRenderer(focused bool) *glamour.TermRenderer {