fix(tmux): serialize nudges to prevent interleaving (#571)
When multiple agents start simultaneously (e.g., `gt up`), each runs `gt nudge deacon session-started` in their SessionStart hook. These nudges arrive concurrently and can interleave in the tmux input buffer, causing: 1. Text from one nudge mixing with another 2. Enter keys not properly submitting messages 3. Garbled input requiring manual intervention This fix adds per-session mutex serialization to NudgeSession() and NudgePane(). Concurrent nudges to the same session now queue and execute one at a time. ## Root Cause The NudgeSession pattern sends text, waits 500ms, sends Escape, waits 100ms, then sends Enter. When multiple nudges arrive within this ~800ms window, their send-keys commands interleave, corrupting the input. ## Alternatives Considered 1. **Delay deacon nudges** - Add sleep before nudge in SessionStart - Simplest (one-line change) - But: doesn't prevent concurrent nudges from multiple agents 2. **Debounce session-started** - Deacon ignores rapid-fire nudges - Medium complexity - But: only helps session-started, not other nudge types 3. **File-based signaling** - Replace tmux nudges with file watches - Avoids tmux input issues entirely - But: significant architectural change 4. **File upstream bug** - Report to Claude Code team - SessionStart hooks fire async and can interleave - But: fix timeline unknown, need robustness now ## Tradeoffs - Concurrent nudges to same session now queue (adds latency) - Memory: one mutex per unique session name (bounded, acceptable) - Does not fix Claude Code's underlying async hook behavior ## Testing - Build passes - All tmux package tests pass - Manual testing: started deacon + multiple witnesses concurrently, nudges processed correctly without garbled input Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -9,12 +9,18 @@ import (
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/steveyegge/gastown/internal/config"
|
||||
"github.com/steveyegge/gastown/internal/constants"
|
||||
)
|
||||
|
||||
// sessionNudgeLocks serializes nudges to the same session.
|
||||
// This prevents interleaving when multiple nudges arrive concurrently,
|
||||
// which can cause garbled input and missed Enter keys.
|
||||
var sessionNudgeLocks sync.Map // map[string]*sync.Mutex
|
||||
|
||||
// versionPattern matches Claude Code version numbers like "2.0.76"
|
||||
var versionPattern = regexp.MustCompile(`^\d+\.\d+\.\d+$`)
|
||||
|
||||
@@ -420,11 +426,28 @@ func (t *Tmux) SendKeysDelayedDebounced(session, keys string, preDelayMs, deboun
|
||||
return t.SendKeysDebounced(session, keys, debounceMs)
|
||||
}
|
||||
|
||||
// getSessionNudgeLock returns the mutex for serializing nudges to a session.
|
||||
// Creates a new mutex if one doesn't exist for this session.
|
||||
func getSessionNudgeLock(session string) *sync.Mutex {
|
||||
actual, _ := sessionNudgeLocks.LoadOrStore(session, &sync.Mutex{})
|
||||
return actual.(*sync.Mutex)
|
||||
}
|
||||
|
||||
// NudgeSession sends a message to a Claude Code session reliably.
|
||||
// This is the canonical way to send messages to Claude sessions.
|
||||
// Uses: literal mode + 500ms debounce + ESC (for vim mode) + separate Enter.
|
||||
// Verification is the Witness's job (AI), not this function.
|
||||
//
|
||||
// IMPORTANT: Nudges to the same session are serialized to prevent interleaving.
|
||||
// If multiple goroutines try to nudge the same session concurrently, they will
|
||||
// queue up and execute one at a time. This prevents garbled input when
|
||||
// SessionStart hooks and nudges arrive simultaneously.
|
||||
func (t *Tmux) NudgeSession(session, message string) error {
|
||||
// Serialize nudges to this session to prevent interleaving
|
||||
lock := getSessionNudgeLock(session)
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
|
||||
// 1. Send text in literal mode (handles special characters)
|
||||
if _, err := t.run("send-keys", "-t", session, "-l", message); err != nil {
|
||||
return err
|
||||
@@ -455,7 +478,13 @@ func (t *Tmux) NudgeSession(session, message string) error {
|
||||
|
||||
// NudgePane sends a message to a specific pane reliably.
|
||||
// Same pattern as NudgeSession but targets a pane ID (e.g., "%9") instead of session name.
|
||||
// Nudges to the same pane are serialized to prevent interleaving.
|
||||
func (t *Tmux) NudgePane(pane, message string) error {
|
||||
// Serialize nudges to this pane to prevent interleaving
|
||||
lock := getSessionNudgeLock(pane)
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
|
||||
// 1. Send text in literal mode (handles special characters)
|
||||
if _, err := t.run("send-keys", "-t", pane, "-l", message); err != nil {
|
||||
return err
|
||||
|
||||
Reference in New Issue
Block a user