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:
aleiby
2026-01-16 15:25:49 -08:00
committed by GitHub
parent a09c6b71d7
commit e344e77921

View File

@@ -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