fix(handoff): don't kill pane processes before respawn (hq-bv7ef)

The previous approach using KillPaneProcessesExcluding/KillPaneProcesses
killed the pane's main process (Claude/node) before calling RespawnPane.
This caused the pane to close (since tmux's remain-on-exit is off by default),
which then made RespawnPane fail because the target pane no longer exists.

The respawn-pane -k flag handles killing atomically - it kills the old process
and starts the new one in a single operation without closing the pane in between.
If orphan processes remain (e.g., Claude ignoring SIGHUP), they will be cleaned
up when the new session starts or by periodic cleanup processes.

This fixes both self-handoff and remote handoff paths.

Fixes: hq-bv7ef

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
furiosa
2026-01-24 15:56:41 -08:00
committed by John Ogle
parent 43f6b63792
commit c4b74ee7bf

View File

@@ -5,7 +5,6 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
"github.com/spf13/cobra" "github.com/spf13/cobra"
@@ -203,29 +202,17 @@ func runHandoff(cmd *cobra.Command, args []string) error {
_ = os.WriteFile(markerPath, []byte(currentSession), 0644) _ = os.WriteFile(markerPath, []byte(currentSession), 0644)
} }
// Set remain-on-exit so the pane survives process death during handoff. // NOTE: We intentionally do NOT kill pane processes before respawning (hq-bv7ef).
// Without this, killing processes causes tmux to destroy the pane before // Previous approach (KillPaneProcessesExcluding) killed the pane's main process,
// we can respawn it. This is essential for tmux session reuse. // which caused the pane to close (remain-on-exit is off by default), making
if err := t.SetRemainOnExit(pane, true); err != nil { // RespawnPane fail because the target pane no longer exists.
style.PrintWarning("could not set remain-on-exit: %v", err)
}
// Kill all processes in the pane before respawning to prevent orphan leaks.
// RespawnPane's -k flag only sends SIGHUP which Claude/Node may ignore.
// //
// IMPORTANT: For self-handoff, we must exclude our own process and parent (Claude Code) // The respawn-pane -k flag handles killing atomically - it kills the old process
// from being killed. Otherwise gt handoff dies before reaching RespawnPane. // and starts the new one in a single operation without closing the pane.
excludePIDs := []string{ // If orphan processes remain (e.g., Claude ignoring SIGHUP), they will be cleaned
strconv.Itoa(os.Getpid()), // gt handoff process // up when the new session starts or when the Witness runs periodic cleanup.
strconv.Itoa(os.Getppid()), // Claude Code (parent)
}
if err := t.KillPaneProcessesExcluding(pane, excludePIDs); err != nil {
// Non-fatal but log the warning
style.PrintWarning("could not kill pane processes: %v", err)
}
// Use respawn-pane -k to atomically kill current process and start new one // Use respawn-pane to atomically kill old process and start new one
// Note: respawn-pane automatically resets remain-on-exit to off
return t.RespawnPane(pane, restartCmd) return t.RespawnPane(pane, restartCmd)
} }
@@ -581,19 +568,10 @@ func handoffRemoteSession(t *tmux.Tmux, targetSession, restartCmd string) error
return nil return nil
} }
// Set remain-on-exit so the pane survives process death during handoff. // NOTE: We intentionally do NOT kill pane processes before respawning (hq-bv7ef).
// Without this, killing processes causes tmux to destroy the pane before // Previous approach (KillPaneProcesses) killed the pane's main process, which caused
// we can respawn it. This is essential for tmux session reuse. // the pane to close (remain-on-exit is off by default), making RespawnPane fail.
if err := t.SetRemainOnExit(targetPane, true); err != nil { // The respawn-pane -k flag handles killing atomically without closing the pane.
style.PrintWarning("could not set remain-on-exit: %v", err)
}
// Kill all processes in the pane before respawning to prevent orphan leaks
// RespawnPane's -k flag only sends SIGHUP which Claude/Node may ignore
if err := t.KillPaneProcesses(targetPane); err != nil {
// Non-fatal but log the warning
style.PrintWarning("could not kill pane processes: %v", err)
}
// Clear scrollback history before respawn (resets copy-mode from [0/N] to [0/0]) // Clear scrollback history before respawn (resets copy-mode from [0/N] to [0/0])
if err := t.ClearHistory(targetPane); err != nil { if err := t.ClearHistory(targetPane); err != nil {
@@ -601,8 +579,7 @@ func handoffRemoteSession(t *tmux.Tmux, targetSession, restartCmd string) error
style.PrintWarning("could not clear history: %v", err) style.PrintWarning("could not clear history: %v", err)
} }
// Respawn the remote session's pane // Respawn the remote session's pane - -k flag atomically kills old process and starts new one
// Note: respawn-pane automatically resets remain-on-exit to off
if err := t.RespawnPane(targetPane, restartCmd); err != nil { if err := t.RespawnPane(targetPane, restartCmd); err != nil {
return fmt.Errorf("respawning pane: %w", err) return fmt.Errorf("respawning pane: %w", err)
} }