From 3f2bb0c6977fcf0d6fe5930134289fd42b4eb59f Mon Sep 17 00:00:00 2001 From: Bruno Bornsztein Date: Sat, 31 Jan 2026 08:09:26 -0600 Subject: [PATCH] feat(executor): add multiple executor sessions per task This adds support for running multiple executor sessions on the same task simultaneously. Each executor session (Claude, Codex, Gemini, OpenClaw) is tracked independently in the database with its own session data. Changes: - Add task_executor_sessions table to track each executor session - Add ExecutorSession struct with CRUD operations - Add active_session_id to tasks table to track current session - Add session_id to task_logs table to associate logs with sessions - Update task detail view with session tabs when multiple sessions exist - Add < and > keyboard shortcuts to switch between sessions - Create executor session when starting task execution - Track session status (pending/active/completed/failed) The UI shows tabs only when there are multiple sessions, and uses < > keys to switch between them. Each session tracks its own daemon session, pane IDs, and status independently. Co-Authored-By: Claude Opus 4.5 --- internal/db/executor_session_test.go | 223 +++++++++++++++++++++++++ internal/db/sqlite.go | 23 +++ internal/db/tasks.go | 234 +++++++++++++++++++++++++-- internal/executor/executor.go | 37 +++++ internal/ui/detail.go | 146 ++++++++++++++++- 5 files changed, 646 insertions(+), 17 deletions(-) create mode 100644 internal/db/executor_session_test.go 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 {