Skip to content

Commit aaab134

Browse files
authored
Merge pull request #517 from bborn/fix/executor-switching-rewrite
Rewrite executor switching for reliability
2 parents 0430942 + 3f88350 commit aaab134

File tree

7 files changed

+198
-47
lines changed

7 files changed

+198
-47
lines changed

internal/executor/codex_executor.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ func (c *CodexExecutor) runCodex(ctx context.Context, task *db.Task, workDir, pr
103103
windowTarget := fmt.Sprintf("%s:%s", daemonSession, windowName)
104104

105105
// Kill ALL existing windows with this name (handles duplicates)
106-
killAllWindowsByNameAllSessions(windowName)
106+
KillAllWindowsByNameAllSessions(windowName)
107107

108108
// Create a temp file for the prompt
109109
promptFile, err := os.CreateTemp("", "task-prompt-*.txt")

internal/executor/executor.go

Lines changed: 55 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -835,7 +835,7 @@ func (e *Executor) cleanupInactiveDoneTasks() {
835835

836836
// Also kill the tmux window to fully clean up
837837
windowName := TmuxWindowName(task.ID)
838-
killAllWindowsByNameAllSessions(windowName)
838+
KillAllWindowsByNameAllSessions(windowName)
839839

840840
e.logLine(task.ID, "system", "Claude process cleaned up (inactive done task)")
841841
}
@@ -1620,30 +1620,57 @@ func TmuxSessionName(taskID int64) string {
16201620
return fmt.Sprintf("%s:%s", getDaemonSessionName(), TmuxWindowName(taskID))
16211621
}
16221622

1623-
// CapturePaneContent captures the last N lines from a task's tmux pane.
1624-
// Returns the trimmed content, or empty string if the pane doesn't exist or capture fails.
1625-
func CapturePaneContent(taskID int64, lines int) string {
1626-
sessionName := TmuxSessionName(taskID)
1627-
1628-
// Check if session exists first
1629-
if err := exec.Command("tmux", "has-session", "-t", sessionName).Run(); err != nil {
1623+
// CapturePaneContent captures the last N lines from a tmux pane.
1624+
// target can be a pane ID (e.g., "%1234") or a window target (e.g., "task-daemon-XXX:2")
1625+
// in which case ".0" is appended to select the first pane.
1626+
// Returns the trimmed content, or empty string if capture fails.
1627+
func CapturePaneContent(windowTarget string, lines int) string {
1628+
if windowTarget == "" {
16301629
return ""
16311630
}
16321631

1633-
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
1634-
defer cancel()
1632+
// If it's already a pane ID (starts with %), use directly; otherwise append .0
1633+
target := windowTarget
1634+
if !strings.HasPrefix(windowTarget, "%") {
1635+
target = windowTarget + ".0"
1636+
}
16351637

1636-
// Capture the last N lines from pane 0 (the executor pane)
1637-
target := sessionName + ".0"
1638-
startLine := fmt.Sprintf("-%d", lines)
1639-
out, err := exec.CommandContext(ctx, "tmux", "capture-pane", "-t", target, "-p", "-S", startLine).Output()
1640-
if err != nil {
1638+
// Try capture with a 3-second timeout and one retry
1639+
for attempt := 0; attempt < 2; attempt++ {
1640+
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
1641+
out, err := exec.CommandContext(ctx, "tmux", "capture-pane", "-t", target, "-p", "-S", fmt.Sprintf("-%d", lines)).Output()
1642+
cancel()
1643+
1644+
if err == nil {
1645+
content := strings.TrimRight(string(out), " \t\n\r")
1646+
if content != "" {
1647+
return content
1648+
}
1649+
}
1650+
1651+
// Only retry once after a short delay
1652+
if attempt == 0 {
1653+
time.Sleep(200 * time.Millisecond)
1654+
}
1655+
}
1656+
1657+
return ""
1658+
}
1659+
1660+
// FormatSessionHandoff formats captured pane content into a handoff prompt for the new executor.
1661+
func FormatSessionHandoff(prevExecutor, capturedContent string) string {
1662+
if capturedContent == "" {
16411663
return ""
16421664
}
16431665

1644-
// Trim trailing whitespace/empty lines
1645-
content := strings.TrimRight(string(out), " \t\n\r")
1646-
return content
1666+
var sb strings.Builder
1667+
sb.WriteString("## Previous Session Context\n\n")
1668+
sb.WriteString(fmt.Sprintf("This task was previously worked on using **%s**. Below is the terminal output from that session.\n", prevExecutor))
1669+
sb.WriteString("Use this context to continue the work seamlessly.\n\n")
1670+
sb.WriteString("```\n")
1671+
sb.WriteString(capturedContent)
1672+
sb.WriteString("\n```\n\n---\n\n")
1673+
return sb.String()
16471674
}
16481675

16491676
// SendKeyToPane sends a key sequence to a task's executor tmux pane.
@@ -1661,9 +1688,9 @@ func SendKeyToPane(taskID int64, keys ...string) error {
16611688
return exec.Command("tmux", args...).Run()
16621689
}
16631690

1664-
// killAllWindowsByNameAllSessions kills ALL windows with a given name across all daemon sessions.
1691+
// KillAllWindowsByNameAllSessions kills ALL windows with a given name across all daemon sessions.
16651692
// Also kills any -shell variant windows.
1666-
func killAllWindowsByNameAllSessions(windowName string) {
1693+
func KillAllWindowsByNameAllSessions(windowName string) {
16671694
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
16681695
defer cancel()
16691696

@@ -2162,7 +2189,7 @@ func (e *Executor) runClaude(ctx context.Context, task *db.Task, workDir, prompt
21622189
windowTarget := fmt.Sprintf("%s:%s", daemonSession, windowName)
21632190

21642191
// Kill ALL existing windows with this name (handles duplicates)
2165-
killAllWindowsByNameAllSessions(windowName)
2192+
KillAllWindowsByNameAllSessions(windowName)
21662193

21672194
// Setup Claude hooks for status updates
21682195
cleanupHooks, err := e.setupClaudeHooks(workDir, task.ID)
@@ -2339,7 +2366,7 @@ func (e *Executor) runClaudeResume(ctx context.Context, task *db.Task, workDir,
23392366
windowTarget := fmt.Sprintf("%s:%s", daemonSession, windowName)
23402367

23412368
// Kill ALL existing windows with this name (handles duplicates)
2342-
killAllWindowsByNameAllSessions(windowName)
2369+
KillAllWindowsByNameAllSessions(windowName)
23432370

23442371
// Setup Claude hooks for status updates
23452372
cleanupHooks, err := e.setupClaudeHooks(workDir, task.ID)
@@ -2527,7 +2554,7 @@ func (e *Executor) resumeClaudeDangerous(task *db.Task, workDir string) bool {
25272554
windowName := TmuxWindowName(taskID)
25282555

25292556
// Kill ALL existing windows with this name (handles duplicates)
2530-
killAllWindowsByNameAllSessions(windowName)
2557+
KillAllWindowsByNameAllSessions(windowName)
25312558

25322559
// Ensure task-daemon session exists for creating new window
25332560
daemonSession, err := ensureTmuxDaemon()
@@ -2692,7 +2719,7 @@ func (e *Executor) resumeClaudeSafe(task *db.Task, workDir string) bool {
26922719
windowName := TmuxWindowName(taskID)
26932720

26942721
// Kill ALL existing windows with this name (handles duplicates)
2695-
killAllWindowsByNameAllSessions(windowName)
2722+
KillAllWindowsByNameAllSessions(windowName)
26962723

26972724
// Ensure task-daemon session exists for creating new window
26982725
daemonSession, err := ensureTmuxDaemon()
@@ -2817,7 +2844,7 @@ func (e *Executor) resumeCodexWithMode(task *db.Task, workDir string, dangerousM
28172844

28182845
windowName := TmuxWindowName(taskID)
28192846
// Kill ALL existing windows with this name (handles duplicates)
2820-
killAllWindowsByNameAllSessions(windowName)
2847+
KillAllWindowsByNameAllSessions(windowName)
28212848

28222849
daemonSession, err := ensureTmuxDaemon()
28232850
if err != nil {
@@ -2921,7 +2948,7 @@ func (e *Executor) resumeGeminiWithMode(task *db.Task, workDir string, dangerous
29212948

29222949
windowName := TmuxWindowName(taskID)
29232950
// Kill ALL existing windows with this name (handles duplicates)
2924-
killAllWindowsByNameAllSessions(windowName)
2951+
KillAllWindowsByNameAllSessions(windowName)
29252952

29262953
daemonSession, err := ensureTmuxDaemon()
29272954
if err != nil {
@@ -4860,7 +4887,7 @@ func (e *Executor) runPi(ctx context.Context, task *db.Task, workDir, prompt str
48604887
windowTarget := fmt.Sprintf("%s:%s", daemonSession, windowName)
48614888

48624889
// Kill ALL existing windows with this name (handles duplicates)
4863-
killAllWindowsByNameAllSessions(windowName)
4890+
KillAllWindowsByNameAllSessions(windowName)
48644891

48654892
// Create a temp file for the prompt (avoids quoting issues)
48664893
promptFile, err := os.CreateTemp("", "task-prompt-*.txt")
@@ -5006,7 +5033,7 @@ func (e *Executor) runPiResume(ctx context.Context, task *db.Task, workDir, prom
50065033
windowTarget := fmt.Sprintf("%s:%s", daemonSession, windowName)
50075034

50085035
// Kill ALL existing windows with this name (handles duplicates)
5009-
killAllWindowsByNameAllSessions(windowName)
5036+
KillAllWindowsByNameAllSessions(windowName)
50105037

50115038
// Create a temp file for the feedback (avoids quoting issues)
50125039
feedbackFile, err := os.CreateTemp("", "task-feedback-*.txt")

internal/executor/gemini_executor.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ func (g *GeminiExecutor) runGemini(ctx context.Context, task *db.Task, workDir,
8383
windowTarget := fmt.Sprintf("%s:%s", daemonSession, windowName)
8484

8585
// Kill ALL existing windows with this name (handles duplicates)
86-
killAllWindowsByNameAllSessions(windowName)
86+
KillAllWindowsByNameAllSessions(windowName)
8787

8888
promptFile, err := os.CreateTemp("", "task-prompt-*.txt")
8989
if err != nil {

internal/executor/openclaw_executor.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ func (o *OpenClawExecutor) runOpenClaw(ctx context.Context, task *db.Task, workD
8585
windowTarget := fmt.Sprintf("%s:%s", daemonSession, windowName)
8686

8787
// Kill ALL existing windows with this name (handles duplicates)
88-
killAllWindowsByNameAllSessions(windowName)
88+
KillAllWindowsByNameAllSessions(windowName)
8989

9090
// Build the prompt content
9191
promptFile, err := os.CreateTemp("", "task-prompt-*.txt")

internal/executor/opencode_executor.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ func (o *OpenCodeExecutor) runOpenCode(ctx context.Context, task *db.Task, workD
8888
windowTarget := fmt.Sprintf("%s:%s", daemonSession, windowName)
8989

9090
// Kill ALL existing windows with this name (handles duplicates)
91-
killAllWindowsByNameAllSessions(windowName)
91+
KillAllWindowsByNameAllSessions(windowName)
9292

9393
// Build the prompt content
9494
promptFile, err := os.CreateTemp("", "task-prompt-*.txt")

internal/ui/app.go

Lines changed: 35 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -863,7 +863,9 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
863863
if m.selectedTask != nil && m.selectedTask.ID == t.ID {
864864
m.selectedTask = t
865865
if m.detailView != nil {
866-
m.detailView.UpdateTask(t)
866+
if cmd := m.detailView.UpdateTask(t); cmd != nil {
867+
cmds = append(cmds, cmd)
868+
}
867869
}
868870
}
869871
}
@@ -897,7 +899,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
897899
// Capture the tmux pane content for richer display of the prompt.
898900
// This shows the actual executor output (including multiple choice options)
899901
// rather than just the hook log summary.
900-
paneContent := executor.CapturePaneContent(t.ID, 15)
902+
paneContent := executor.CapturePaneContent(executor.TmuxSessionName(t.ID), 15)
901903
if paneContent != "" {
902904
m.executorPrompts[t.ID] = paneContent
903905
} else {
@@ -1057,7 +1059,9 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
10571059
if m.selectedTask != nil && msg.task != nil && m.selectedTask.ID == msg.task.ID {
10581060
m.selectedTask = msg.task
10591061
if m.detailView != nil {
1060-
m.detailView.UpdateTask(msg.task)
1062+
if cmd := m.detailView.UpdateTask(msg.task); cmd != nil {
1063+
cmds = append(cmds, cmd)
1064+
}
10611065
}
10621066
}
10631067
cmds = append(cmds, m.loadTasks())
@@ -1085,7 +1089,9 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
10851089
if m.selectedTask != nil && m.selectedTask.ID == msg.task.ID {
10861090
m.selectedTask = msg.task
10871091
if m.detailView != nil {
1088-
m.detailView.UpdateTask(msg.task)
1092+
if cmd := m.detailView.UpdateTask(msg.task); cmd != nil {
1093+
cmds = append(cmds, cmd)
1094+
}
10891095
}
10901096
}
10911097
if msg.task.Pinned {
@@ -1101,7 +1107,9 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
11011107
if msg.err == nil && m.selectedTask != nil && m.detailView != nil {
11021108
if task, err := m.db.GetTask(m.selectedTask.ID); err == nil && task != nil {
11031109
m.selectedTask = task
1104-
m.detailView.UpdateTask(task)
1110+
if cmd := m.detailView.UpdateTask(task); cmd != nil {
1111+
cmds = append(cmds, cmd)
1112+
}
11051113
}
11061114
}
11071115
cmds = append(cmds, m.loadTasks())
@@ -1135,7 +1143,9 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
11351143
if task != nil {
11361144
m.selectedTask = task
11371145
if m.detailView != nil {
1138-
m.detailView.UpdateTask(task)
1146+
if cmd := m.detailView.UpdateTask(task); cmd != nil {
1147+
cmds = append(cmds, cmd)
1148+
}
11391149
}
11401150
if task.DangerousMode {
11411151
m.notification = IconBlocked() + " Dangerous mode enabled"
@@ -1173,7 +1183,9 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
11731183
if task != nil {
11741184
m.selectedTask = task
11751185
if m.detailView != nil {
1176-
m.detailView.UpdateTask(task)
1186+
if cmd := m.detailView.UpdateTask(task); cmd != nil {
1187+
cmds = append(cmds, cmd)
1188+
}
11771189
}
11781190
}
11791191
}
@@ -1263,7 +1275,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
12631275
} else if prompt, isQ := m.latestChoicePrompt(event.TaskID); prompt != "" {
12641276
m.tasksNeedingInput[event.TaskID] = true
12651277
m.questionPrompts[event.TaskID] = isQ
1266-
paneContent := executor.CapturePaneContent(event.TaskID, 15)
1278+
paneContent := executor.CapturePaneContent(executor.TmuxSessionName(event.TaskID), 15)
12671279
if paneContent != "" {
12681280
m.executorPrompts[event.TaskID] = paneContent
12691281
} else {
@@ -1286,7 +1298,9 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
12861298
if m.selectedTask != nil && m.selectedTask.ID == event.TaskID {
12871299
m.selectedTask = event.Task
12881300
if m.detailView != nil {
1289-
m.detailView.UpdateTask(event.Task)
1301+
if cmd := m.detailView.UpdateTask(event.Task); cmd != nil {
1302+
cmds = append(cmds, cmd)
1303+
}
12901304
}
12911305
}
12921306
}
@@ -2560,11 +2574,15 @@ func (m *AppModel) updateDetail(msg tea.Msg) (tea.Model, tea.Cmd) {
25602574
}
25612575
// Immediately update UI for responsiveness
25622576
m.selectedTask.Status = db.StatusQueued
2577+
var switchCmd tea.Cmd
25632578
if m.detailView != nil {
2564-
m.detailView.UpdateTask(m.selectedTask)
2579+
switchCmd = m.detailView.UpdateTask(m.selectedTask)
25652580
}
25662581
// Update task in the list and kanban
25672582
m.updateTaskInList(m.selectedTask)
2583+
if switchCmd != nil {
2584+
return m, tea.Batch(switchCmd, m.queueTask(m.selectedTask.ID))
2585+
}
25682586
return m, m.queueTask(m.selectedTask.ID)
25692587
}
25702588
if key.Matches(keyMsg, m.keys.Retry) && m.selectedTask != nil {
@@ -2623,7 +2641,7 @@ func (m *AppModel) updateDetail(msg tea.Msg) (tea.Model, tea.Cmd) {
26232641
// Only allow toggling dangerous mode if task is processing or blocked
26242642
if m.selectedTask.Status == db.StatusProcessing || m.selectedTask.Status == db.StatusBlocked {
26252643
// Break panes back to daemon BEFORE toggling so the executor can kill them.
2626-
// If panes are joined to task-ui, killAllWindowsByNameAllSessions won't find them.
2644+
// If panes are joined to task-ui, KillAllWindowsByNameAllSessions won't find them.
26272645
if m.detailView != nil {
26282646
m.detailView.Cleanup()
26292647
}
@@ -3446,16 +3464,20 @@ func (m *AppModel) updateChangeStatus(msg tea.Msg) (tea.Model, tea.Cmd) {
34463464
m.updateTaskInList(m.pendingChangeStatusTask)
34473465

34483466
// Update detail view if showing this task
3467+
var switchCmd tea.Cmd
34493468
if m.selectedTask != nil && m.selectedTask.ID == taskID {
34503469
m.selectedTask.Status = newStatus
34513470
if m.detailView != nil {
3452-
m.detailView.UpdateTask(m.selectedTask)
3471+
switchCmd = m.detailView.UpdateTask(m.selectedTask)
34533472
}
34543473
}
34553474

34563475
m.pendingChangeStatusTask = nil
34573476
m.changeStatusForm = nil
34583477
m.currentView = m.previousView
3478+
if switchCmd != nil {
3479+
return m, tea.Batch(switchCmd, m.changeTaskStatus(taskID, newStatus))
3480+
}
34593481
return m, m.changeTaskStatus(taskID, newStatus)
34603482
}
34613483
// Cancelled or no selection
@@ -4256,7 +4278,7 @@ func (m *AppModel) detectPermissionPrompt(taskID int64) bool {
42564278
}
42574279
m.tasksNeedingInput[taskID] = true
42584280
m.questionPrompts[taskID] = isQ
4259-
paneContent := executor.CapturePaneContent(taskID, 15)
4281+
paneContent := executor.CapturePaneContent(executor.TmuxSessionName(taskID), 15)
42604282
if paneContent != "" {
42614283
m.executorPrompts[taskID] = paneContent
42624284
} else {

0 commit comments

Comments
 (0)