fix(tmux): wait for shell ready before sending keys (#264)

Add WaitForShellReady call before SendKeys in all agent managers
(deacon, mayor, witness, refinery). This prevents intermittent
"can't find pane" errors that occur when the tmux session is
created but the shell isn't ready to receive input yet.

The issue manifests under load (e.g., during `gt up` when multiple
agents start in sequence) where the 200ms delay in SendKeysDelayed
isn't sufficient for the pane to be fully initialized.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Julian Knutsen
2026-01-07 20:49:25 -08:00
committed by GitHub
parent a9ed342be6
commit 28a9de64d5
6 changed files with 30 additions and 0 deletions

View File

@@ -197,6 +197,11 @@ func (b *Boot) spawnTmux() error {
// Launch Claude with environment exported inline and initial triage prompt // Launch Claude with environment exported inline and initial triage prompt
// The "gt boot triage" prompt tells Boot to immediately start triage (GUPP principle) // The "gt boot triage" prompt tells Boot to immediately start triage (GUPP principle)
startCmd := config.BuildAgentStartupCommand("boot", "deacon-boot", "", "gt boot triage") startCmd := config.BuildAgentStartupCommand("boot", "deacon-boot", "", "gt boot triage")
// Wait for shell to be ready before sending keys (prevents "can't find pane" under load)
if err := b.tmux.WaitForShellReady(SessionName, 5*time.Second); err != nil {
_ = b.tmux.KillSession(SessionName)
return fmt.Errorf("waiting for shell: %w", err)
}
if err := b.tmux.SendKeys(SessionName, startCmd); err != nil { if err := b.tmux.SendKeys(SessionName, startCmd); err != nil {
return fmt.Errorf("sending startup command: %w", err) return fmt.Errorf("sending startup command: %w", err)
} }

View File

@@ -99,6 +99,11 @@ func (m *Manager) Start() error {
runtimeCmd, runtimeCmd,
) )
// Wait for shell to be ready before sending keys (prevents "can't find pane" under load)
if err := t.WaitForShellReady(sessionID, 5*time.Second); err != nil {
_ = t.KillSession(sessionID)
return fmt.Errorf("waiting for shell: %w", err)
}
if err := t.SendKeysDelayed(sessionID, respawnCmd, 200); err != nil { if err := t.SendKeysDelayed(sessionID, respawnCmd, 200); err != nil {
_ = t.KillSession(sessionID) // best-effort cleanup _ = t.KillSession(sessionID) // best-effort cleanup
return fmt.Errorf("starting Claude agent: %w", err) return fmt.Errorf("starting Claude agent: %w", err)

View File

@@ -98,6 +98,11 @@ func (m *Manager) Start(agentOverride string) error {
_ = t.KillSession(sessionID) // best-effort cleanup _ = t.KillSession(sessionID) // best-effort cleanup
return fmt.Errorf("building startup command: %w", err) return fmt.Errorf("building startup command: %w", err)
} }
// Wait for shell to be ready before sending keys (prevents "can't find pane" under load)
if err := t.WaitForShellReady(sessionID, 5*time.Second); err != nil {
_ = t.KillSession(sessionID)
return fmt.Errorf("waiting for shell: %w", err)
}
if err := t.SendKeysDelayed(sessionID, startupCmd, 200); err != nil { if err := t.SendKeysDelayed(sessionID, startupCmd, 200); err != nil {
_ = t.KillSession(sessionID) // best-effort cleanup _ = t.KillSession(sessionID) // best-effort cleanup
return fmt.Errorf("starting Claude agent: %w", err) return fmt.Errorf("starting Claude agent: %w", err)

View File

@@ -183,6 +183,11 @@ func (m *SessionManager) Start(polecat string, opts SessionStartOptions) error {
if command == "" { if command == "" {
command = config.BuildPolecatStartupCommand(m.rig.Name, polecat, m.rig.Path, "") command = config.BuildPolecatStartupCommand(m.rig.Name, polecat, m.rig.Path, "")
} }
// Wait for shell to be ready before sending keys (prevents "can't find pane" under load)
if err := m.tmux.WaitForShellReady(sessionID, 5*time.Second); err != nil {
_ = m.tmux.KillSession(sessionID)
return fmt.Errorf("waiting for shell: %w", err)
}
if err := m.tmux.SendKeys(sessionID, command); err != nil { if err := m.tmux.SendKeys(sessionID, command); err != nil {
return fmt.Errorf("sending command: %w", err) return fmt.Errorf("sending command: %w", err)
} }

View File

@@ -210,6 +210,11 @@ func (m *Manager) Start(foreground bool) error {
// Restarts are handled by daemon via LIFECYCLE mail, not shell loops // Restarts are handled by daemon via LIFECYCLE mail, not shell loops
// Export GT_ROLE and BD_ACTOR in the command since tmux SetEnvironment only affects new panes // Export GT_ROLE and BD_ACTOR in the command since tmux SetEnvironment only affects new panes
command := config.BuildAgentStartupCommand("refinery", bdActor, m.rig.Path, "") command := config.BuildAgentStartupCommand("refinery", bdActor, m.rig.Path, "")
// Wait for shell to be ready before sending keys (prevents "can't find pane" under load)
if err := t.WaitForShellReady(sessionID, 5*time.Second); err != nil {
_ = t.KillSession(sessionID)
return fmt.Errorf("waiting for shell: %w", err)
}
if err := t.SendKeys(sessionID, command); err != nil { if err := t.SendKeys(sessionID, command); err != nil {
// Clean up the session on failure (best-effort cleanup) // Clean up the session on failure (best-effort cleanup)
_ = t.KillSession(sessionID) _ = t.KillSession(sessionID)

View File

@@ -183,6 +183,11 @@ func (m *Manager) Start(foreground bool) error {
// Export GT_ROLE and BD_ACTOR in the command since tmux SetEnvironment only affects new panes // Export GT_ROLE and BD_ACTOR in the command since tmux SetEnvironment only affects new panes
// Pass m.rig.Path so rig agent settings are honored (not town-level defaults) // Pass m.rig.Path so rig agent settings are honored (not town-level defaults)
command := config.BuildAgentStartupCommand("witness", bdActor, m.rig.Path, "") command := config.BuildAgentStartupCommand("witness", bdActor, m.rig.Path, "")
// Wait for shell to be ready before sending keys (prevents "can't find pane" under load)
if err := t.WaitForShellReady(sessionID, 5*time.Second); err != nil {
_ = t.KillSession(sessionID)
return fmt.Errorf("waiting for shell: %w", err)
}
if err := t.SendKeys(sessionID, command); err != nil { if err := t.SendKeys(sessionID, command); err != nil {
_ = t.KillSession(sessionID) // best-effort cleanup _ = t.KillSession(sessionID) // best-effort cleanup
return fmt.Errorf("starting Claude agent: %w", err) return fmt.Errorf("starting Claude agent: %w", err)