From 87fde4b4fdd13203d803801748c18807b4c342f1 Mon Sep 17 00:00:00 2001 From: dementus Date: Tue, 13 Jan 2026 01:27:03 -0800 Subject: [PATCH] feat(spawn): migrate to NewSessionWithCommand pattern Migrate witness, boot, and deacon spawns to use NewSessionWithCommand instead of NewSession+SendKeys to ensure BD_ACTOR is visible in the process tree for orphan detection via ps. Refs: gt-emi5b Co-Authored-By: Claude Opus 4.5 --- internal/boot/boot.go | 21 ++++++---------- internal/cmd/deacon.go | 24 ++++++++---------- internal/witness/manager.go | 49 ++++++++++++++----------------------- 3 files changed, 36 insertions(+), 58 deletions(-) diff --git a/internal/boot/boot.go b/internal/boot/boot.go index af4350aa..f12571d9 100644 --- a/internal/boot/boot.go +++ b/internal/boot/boot.go @@ -170,8 +170,13 @@ func (b *Boot) spawnTmux() error { return fmt.Errorf("ensuring boot dir: %w", err) } - // Create new session in boot directory (not deacon dir) so Claude reads Boot's CLAUDE.md - if err := b.tmux.NewSession(SessionName, b.bootDir); err != nil { + // Build startup command first + // The "gt boot triage" prompt tells Boot to immediately start triage (GUPP principle) + startCmd := config.BuildAgentStartupCommand("boot", "deacon-boot", "", "gt boot triage") + + // Create session with command directly to avoid send-keys race condition. + // See: https://github.com/anthropics/gastown/issues/280 + if err := b.tmux.NewSessionWithCommand(SessionName, b.bootDir, startCmd); err != nil { return fmt.Errorf("creating boot session: %w", err) } @@ -185,18 +190,6 @@ func (b *Boot) spawnTmux() error { _ = b.tmux.SetEnvironment(SessionName, k, v) } - // Launch Claude with environment exported inline and initial triage prompt - // The "gt boot triage" prompt tells Boot to immediately start triage (GUPP principle) - 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 { - return fmt.Errorf("sending startup command: %w", err) - } - return nil } diff --git a/internal/cmd/deacon.go b/internal/cmd/deacon.go index 399d15a3..cb0da9f3 100644 --- a/internal/cmd/deacon.go +++ b/internal/cmd/deacon.go @@ -351,9 +351,17 @@ func startDeaconSession(t *tmux.Tmux, sessionName, agentOverride string) error { style.PrintWarning("Could not create deacon settings: %v", err) } - // Create session in deacon directory + // Build startup command first + // Export GT_ROLE and BD_ACTOR in the command since tmux SetEnvironment only affects new panes + startupCmd, err := config.BuildAgentStartupCommandWithAgentOverride("deacon", "deacon", "", "", agentOverride) + if err != nil { + return fmt.Errorf("building startup command: %w", err) + } + + // Create session with command directly to avoid send-keys race condition. + // See: https://github.com/anthropics/gastown/issues/280 fmt.Println("Starting Deacon session...") - if err := t.NewSession(sessionName, deaconDir); err != nil { + if err := t.NewSessionWithCommand(sessionName, deaconDir, startupCmd); err != nil { return fmt.Errorf("creating session: %w", err) } @@ -373,18 +381,6 @@ func startDeaconSession(t *tmux.Tmux, sessionName, agentOverride string) error { theme := tmux.DeaconTheme() _ = t.ConfigureGasTownSession(sessionName, theme, "", "Deacon", "health-check") - // Launch Claude directly (no shell respawn loop) - // Restarts are handled by daemon via ensureDeaconRunning on each heartbeat - // The startup hook handles context loading automatically - // Export GT_ROLE and BD_ACTOR in the command since tmux SetEnvironment only affects new panes - startupCmd, err := config.BuildAgentStartupCommandWithAgentOverride("deacon", "deacon", "", "", agentOverride) - if err != nil { - return fmt.Errorf("building startup command: %w", err) - } - if err := t.SendKeys(sessionName, startupCmd); err != nil { - return fmt.Errorf("sending command: %w", err) - } - // Wait for Claude to start (non-fatal) if err := t.WaitForCommand(sessionName, constants.SupportedShells, constants.ClaudeStartTimeout); err != nil { // Non-fatal diff --git a/internal/witness/manager.go b/internal/witness/manager.go index c88ca699..37ce78d0 100644 --- a/internal/witness/manager.go +++ b/internal/witness/manager.go @@ -153,23 +153,28 @@ func (m *Manager) Start(foreground bool, agentOverride string, envOverrides []st return fmt.Errorf("ensuring Claude settings: %w", err) } - // Create new tmux session - if err := t.NewSession(sessionID, witnessDir); err != nil { - return fmt.Errorf("creating tmux session: %w", err) - } - - // Apply Gas Town theming (non-fatal: theming failure doesn't affect operation) - theme := tmux.AssignTheme(m.rig.Name) - _ = t.ConfigureGasTownSession(sessionID, theme, m.rig.Name, "witness", "witness") - roleConfig, err := m.roleConfig() if err != nil { - _ = t.KillSession(sessionID) return err } townRoot := m.townRoot() + // Build startup command first + // NOTE: No gt prime injection needed - SessionStart hook handles it automatically + // 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) + command, err := buildWitnessStartCommand(m.rig.Path, m.rig.Name, townRoot, agentOverride, roleConfig) + if err != nil { + return err + } + + // Create session with command directly to avoid send-keys race condition. + // See: https://github.com/anthropics/gastown/issues/280 + if err := t.NewSessionWithCommand(sessionID, witnessDir, command); err != nil { + return fmt.Errorf("creating tmux session: %w", err) + } + // Set environment variables (non-fatal: session works without these) // Use centralized AgentEnv for consistency across all role startup paths envVars := config.AgentEnv(config.AgentEnvConfig{ @@ -192,6 +197,10 @@ func (m *Manager) Start(foreground bool, agentOverride string, envOverrides []st } } + // Apply Gas Town theming (non-fatal: theming failure doesn't affect operation) + theme := tmux.AssignTheme(m.rig.Name) + _ = t.ConfigureGasTownSession(sessionID, theme, m.rig.Name, "witness", "witness") + // Update state to running now := time.Now() w.State = StateRunning @@ -203,26 +212,6 @@ func (m *Manager) Start(foreground bool, agentOverride string, envOverrides []st return fmt.Errorf("saving state: %w", err) } - // Launch Claude directly (no shell respawn loop) - // Restarts are handled by daemon via LIFECYCLE mail or deacon health-scan - // NOTE: No gt prime injection needed - SessionStart hook handles it automatically - // 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) - command, err := buildWitnessStartCommand(m.rig.Path, m.rig.Name, townRoot, agentOverride, roleConfig) - if err != nil { - _ = t.KillSession(sessionID) - return 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.SendKeys(sessionID, command); err != nil { - _ = t.KillSession(sessionID) // best-effort cleanup - return fmt.Errorf("starting Claude agent: %w", err) - } - // Wait for Claude to start (non-fatal). if err := t.WaitForCommand(sessionID, constants.SupportedShells, constants.ClaudeStartTimeout); err != nil { // Non-fatal - try to continue anyway