From 31a32c084bc08f1fefb6a5d04c6189d524a00a48 Mon Sep 17 00:00:00 2001 From: mayor Date: Tue, 6 Jan 2026 01:28:16 -0800 Subject: [PATCH] Unify agent startup with GUPP propulsion nudge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Witness and Refinery startup was duplicated across cmd/witness.go, cmd/up.go, cmd/rig.go, and daemon.go. Worse, not all code paths sent the propulsion nudge (GUPP - Gas Town Universal Propulsion Principle). Now unified in Manager.Start() which handles everything including nudges. Changes: - witness/manager.go: Full rewrite with session creation, env vars, theming, WaitForClaudeReady, startup nudge, and propulsion nudge (GUPP) - refinery/manager.go: Add propulsion nudge sequence after Claude startup - cmd/witness.go: Simplify to just call mgr.Start(), remove ensureWitnessSession - cmd/rig.go: Use witness.Manager.Start() instead of inline session creation - cmd/start.go: Use witness.Manager.Start() - cmd/up.go: Use witness.Manager.Start(), remove ensureWitness(), add EnsureSettingsForRole in ensureSession() - daemon.go: Use witness.Manager.Start() and refinery.Manager.Start() for unified startup with proper nudges This ensures all agent startup paths (gt witness start, gt rig boot, gt up, daemon restarts) consistently apply GUPP propulsion nudges. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/cmd/rig.go | 47 +++++----- internal/cmd/start.go | 13 ++- internal/cmd/up.go | 75 +++++----------- internal/cmd/witness.go | 161 ++++------------------------------- internal/daemon/daemon.go | 140 ++++++++---------------------- internal/refinery/manager.go | 27 ++++++ internal/witness/manager.go | 153 +++++++++++++++++++++++++++++++-- 7 files changed, 277 insertions(+), 339 deletions(-) diff --git a/internal/cmd/rig.go b/internal/cmd/rig.go index 93644df2..fa1ee1a7 100644 --- a/internal/cmd/rig.go +++ b/internal/cmd/rig.go @@ -737,15 +737,14 @@ func runRigBoot(cmd *cobra.Command, args []string) error { skipped = append(skipped, "witness (already running)") } else { fmt.Printf(" Starting witness...\n") - // Use ensureWitnessSession to create tmux session (same as gt witness start) - created, err := ensureWitnessSession(rigName, r) - if err != nil { - return fmt.Errorf("starting witness: %w", err) - } - if created { - // Update manager state to reflect running session - witMgr := witness.NewManager(r) - _ = witMgr.Start() // non-fatal: state file update + witMgr := witness.NewManager(r) + if err := witMgr.Start(false); err != nil { + if err == witness.ErrAlreadyRunning { + skipped = append(skipped, "witness (already running)") + } else { + return fmt.Errorf("starting witness: %w", err) + } + } else { started = append(started, "witness") } } @@ -818,13 +817,15 @@ func runRigStart(cmd *cobra.Command, args []string) error { skipped = append(skipped, "witness") } else { fmt.Printf(" Starting witness...\n") - created, err := ensureWitnessSession(rigName, r) - if err != nil { - fmt.Printf(" %s Failed to start witness: %v\n", style.Warning.Render("⚠"), err) - hasError = true - } else if created { - witMgr := witness.NewManager(r) - _ = witMgr.Start() + witMgr := witness.NewManager(r) + if err := witMgr.Start(false); err != nil { + if err == witness.ErrAlreadyRunning { + skipped = append(skipped, "witness") + } else { + fmt.Printf(" %s Failed to start witness: %v\n", style.Warning.Render("⚠"), err) + hasError = true + } + } else { started = append(started, "witness") } } @@ -1385,12 +1386,14 @@ func runRigRestart(cmd *cobra.Command, args []string) error { skipped = append(skipped, "witness") } else { fmt.Printf(" Starting witness...\n") - created, err := ensureWitnessSession(rigName, r) - if err != nil { - fmt.Printf(" %s Failed to start witness: %v\n", style.Warning.Render("⚠"), err) - startErrors = append(startErrors, fmt.Sprintf("witness: %v", err)) - } else if created { - _ = witMgr.Start() + if err := witMgr.Start(false); err != nil { + if err == witness.ErrAlreadyRunning { + skipped = append(skipped, "witness") + } else { + fmt.Printf(" %s Failed to start witness: %v\n", style.Warning.Render("⚠"), err) + startErrors = append(startErrors, fmt.Sprintf("witness: %v", err)) + } + } else { started = append(started, "witness") } } diff --git a/internal/cmd/start.go b/internal/cmd/start.go index b0b0436a..f9865c23 100644 --- a/internal/cmd/start.go +++ b/internal/cmd/start.go @@ -19,6 +19,7 @@ import ( "github.com/steveyegge/gastown/internal/session" "github.com/steveyegge/gastown/internal/style" "github.com/steveyegge/gastown/internal/tmux" + "github.com/steveyegge/gastown/internal/witness" "github.com/steveyegge/gastown/internal/workspace" ) @@ -225,10 +226,14 @@ func startRigAgents(t *tmux.Tmux, townRoot string) { if witnessRunning { fmt.Printf(" %s %s witness already running\n", style.Dim.Render("○"), r.Name) } else { - created, err := ensureWitnessSession(r.Name, r) - if err != nil { - fmt.Printf(" %s %s witness failed: %v\n", style.Dim.Render("○"), r.Name, err) - } else if created { + witMgr := witness.NewManager(r) + if err := witMgr.Start(false); err != nil { + if err == witness.ErrAlreadyRunning { + fmt.Printf(" %s %s witness already running\n", style.Dim.Render("○"), r.Name) + } else { + fmt.Printf(" %s %s witness failed: %v\n", style.Dim.Render("○"), r.Name, err) + } + } else { fmt.Printf(" %s %s witness started\n", style.Bold.Render("✓"), r.Name) } } diff --git a/internal/cmd/up.go b/internal/cmd/up.go index ea145df4..671cf8a6 100644 --- a/internal/cmd/up.go +++ b/internal/cmd/up.go @@ -10,6 +10,7 @@ import ( "github.com/spf13/cobra" "github.com/steveyegge/gastown/internal/beads" + "github.com/steveyegge/gastown/internal/claude" "github.com/steveyegge/gastown/internal/config" "github.com/steveyegge/gastown/internal/constants" "github.com/steveyegge/gastown/internal/daemon" @@ -18,6 +19,7 @@ import ( "github.com/steveyegge/gastown/internal/session" "github.com/steveyegge/gastown/internal/style" "github.com/steveyegge/gastown/internal/tmux" + "github.com/steveyegge/gastown/internal/witness" "github.com/steveyegge/gastown/internal/workspace" ) @@ -103,11 +105,22 @@ func runUp(cmd *cobra.Command, args []string) error { rigs := discoverRigs(townRoot) for _, rigName := range rigs { sessionName := fmt.Sprintf("gt-%s-witness", rigName) - rigPath := filepath.Join(townRoot, rigName) - if err := ensureWitness(t, sessionName, rigPath, rigName); err != nil { + _, r, err := getRig(rigName) + if err != nil { printStatus(fmt.Sprintf("Witness (%s)", rigName), false, err.Error()) allOK = false + continue + } + + mgr := witness.NewManager(r) + if err := mgr.Start(false); err != nil { + if err == witness.ErrAlreadyRunning { + printStatus(fmt.Sprintf("Witness (%s)", rigName), true, sessionName) + } else { + printStatus(fmt.Sprintf("Witness (%s)", rigName), false, err.Error()) + allOK = false + } } else { printStatus(fmt.Sprintf("Witness (%s)", rigName), true, sessionName) } @@ -244,6 +257,11 @@ func ensureSession(t *tmux.Tmux, sessionName, workDir, role string) error { return nil } + // Ensure Claude settings exist + if err := claude.EnsureSettingsForRole(workDir, role); err != nil { + return fmt.Errorf("ensuring Claude settings: %w", err) + } + // Create session if err := t.NewSession(sessionName, workDir); err != nil { return err @@ -302,59 +320,6 @@ func ensureSession(t *tmux.Tmux, sessionName, workDir, role string) error { return nil } -// ensureWitness starts a witness session for a rig. -func ensureWitness(t *tmux.Tmux, sessionName, rigPath, rigName string) error { - running, err := t.HasSession(sessionName) - if err != nil { - return err - } - if running { - return nil - } - - // Create session in rig directory - if err := t.NewSession(sessionName, rigPath); err != nil { - return err - } - - // Set environment (non-fatal: session works without these) - bdActor := fmt.Sprintf("%s/witness", rigName) - _ = t.SetEnvironment(sessionName, "GT_ROLE", "witness") - _ = t.SetEnvironment(sessionName, "GT_RIG", rigName) - _ = t.SetEnvironment(sessionName, "BD_ACTOR", bdActor) - - // Apply theme (non-fatal: theming failure doesn't affect operation) - theme := tmux.AssignTheme(rigName) - _ = t.ConfigureGasTownSession(sessionName, theme, "", "Witness", rigName) - - // Launch Claude using runtime config - // Export GT_ROLE and BD_ACTOR in the command since tmux SetEnvironment only affects new panes - claudeCmd := config.BuildAgentStartupCommand("witness", bdActor, rigPath, "") - if err := t.SendKeysDelayed(sessionName, claudeCmd, 200); err != nil { - return err - } - - // Wait for Claude to start (non-fatal) - if err := t.WaitForCommand(sessionName, constants.SupportedShells, constants.ClaudeStartTimeout); err != nil { - // Non-fatal - } - - // Accept bypass permissions warning dialog if it appears. - _ = t.AcceptBypassPermissionsWarning(sessionName) - - time.Sleep(constants.ShutdownNotifyDelay) - - // Inject startup nudge for predecessor discovery via /resume - address := fmt.Sprintf("%s/witness", rigName) - _ = session.StartupNudge(t, sessionName, session.StartupNudgeConfig{ - Recipient: address, - Sender: "deacon", - Topic: "patrol", - }) // Non-fatal - - return nil -} - // discoverRigs finds all rigs in the town. func discoverRigs(townRoot string) []string { var rigs []string diff --git a/internal/cmd/witness.go b/internal/cmd/witness.go index d68d3a46..c431b4c0 100644 --- a/internal/cmd/witness.go +++ b/internal/cmd/witness.go @@ -5,15 +5,9 @@ import ( "fmt" "os" "os/exec" - "path/filepath" - "time" "github.com/spf13/cobra" - "github.com/steveyegge/gastown/internal/claude" - "github.com/steveyegge/gastown/internal/config" - "github.com/steveyegge/gastown/internal/constants" "github.com/steveyegge/gastown/internal/rig" - "github.com/steveyegge/gastown/internal/session" "github.com/steveyegge/gastown/internal/style" "github.com/steveyegge/gastown/internal/tmux" "github.com/steveyegge/gastown/internal/witness" @@ -136,42 +130,28 @@ func getWitnessManager(rigName string) (*witness.Manager, *rig.Rig, error) { func runWitnessStart(cmd *cobra.Command, args []string) error { rigName := args[0] - mgr, r, err := getWitnessManager(rigName) + mgr, _, err := getWitnessManager(rigName) if err != nil { return err } fmt.Printf("Starting witness for %s...\n", rigName) - if witnessForeground { - // Foreground mode is no longer supported - patrol logic moved to mol-witness-patrol - if err := mgr.Start(); err != nil { - if err == witness.ErrAlreadyRunning { - fmt.Printf("%s Witness is already running\n", style.Dim.Render("⚠")) - return nil - } - return fmt.Errorf("starting witness: %w", err) + if err := mgr.Start(witnessForeground); err != nil { + if err == witness.ErrAlreadyRunning { + fmt.Printf("%s Witness is already running\n", style.Dim.Render("⚠")) + fmt.Printf(" %s\n", style.Dim.Render("Use 'gt witness attach' to connect")) + return nil } + return fmt.Errorf("starting witness: %w", err) + } + + if witnessForeground { fmt.Printf("%s Note: Foreground mode no longer runs patrol loop\n", style.Dim.Render("⚠")) fmt.Printf(" %s\n", style.Dim.Render("Patrol logic is now handled by mol-witness-patrol molecule")) return nil } - // Background mode: create tmux session with Claude - created, err := ensureWitnessSession(rigName, r) - if err != nil { - return err - } - - if !created { - fmt.Printf("%s Witness session already running\n", style.Dim.Render("⚠")) - fmt.Printf(" %s\n", style.Dim.Render("Use 'gt witness attach' to connect")) - return nil - } - - // Update manager state to reflect running session (non-fatal: state file update) - _ = mgr.Start() - fmt.Printf("%s Witness started for %s\n", style.Bold.Render("✓"), rigName) fmt.Printf(" %s\n", style.Dim.Render("Use 'gt witness attach' to connect")) fmt.Printf(" %s\n", style.Dim.Render("Use 'gt witness status' to check progress")) @@ -283,95 +263,6 @@ func witnessSessionName(rigName string) string { return fmt.Sprintf("gt-%s-witness", rigName) } -// ensureWitnessSession creates a witness tmux session if it doesn't exist. -// Returns true if a new session was created, false if it already existed (and is healthy). -// Implements 'ensure' semantics: if session exists but Claude is dead (zombie), kills and recreates. -func ensureWitnessSession(rigName string, r *rig.Rig) (bool, error) { - t := tmux.NewTmux() - sessionName := witnessSessionName(rigName) - - // Check if session already exists - running, err := t.HasSession(sessionName) - if err != nil { - return false, fmt.Errorf("checking session: %w", err) - } - - if running { - // Session exists - check if Claude is actually running (healthy vs zombie) - if t.IsClaudeRunning(sessionName) { - // Healthy - Claude is running - return false, nil - } - // Zombie - tmux alive but Claude dead. Kill and recreate. - fmt.Printf("%s Detected zombie session (tmux alive, Claude dead). Recreating...\n", style.Dim.Render("⚠")) - if err := t.KillSession(sessionName); err != nil { - return false, fmt.Errorf("killing zombie session: %w", err) - } - } - - // Working directory is the witness's rig clone (if it exists) or witness dir - // This ensures gt prime detects the Witness role correctly - witnessDir := filepath.Join(r.Path, "witness", "rig") - if _, err := os.Stat(witnessDir); os.IsNotExist(err) { - // Try witness/ without rig subdirectory - witnessDir = filepath.Join(r.Path, "witness") - if _, err := os.Stat(witnessDir); os.IsNotExist(err) { - // Fall back to rig path (shouldn't happen in normal setup) - witnessDir = r.Path - } - } - - // Ensure Claude settings exist (autonomous role needs mail in SessionStart) - if err := claude.EnsureSettingsForRole(witnessDir, "witness"); err != nil { - return false, fmt.Errorf("ensuring Claude settings: %w", err) - } - - // Create new tmux session - if err := t.NewSession(sessionName, witnessDir); err != nil { - return false, fmt.Errorf("creating session: %w", err) - } - - // Set environment - bdActor := fmt.Sprintf("%s/witness", rigName) - _ = t.SetEnvironment(sessionName, "GT_ROLE", "witness") - _ = t.SetEnvironment(sessionName, "GT_RIG", rigName) - _ = t.SetEnvironment(sessionName, "BD_ACTOR", bdActor) - - // Apply Gas Town theming (non-fatal: theming failure doesn't affect operation) - theme := tmux.AssignTheme(rigName) - _ = t.ConfigureGasTownSession(sessionName, theme, rigName, "witness", "witness") - - // 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 - if err := t.SendKeys(sessionName, config.BuildAgentStartupCommand("witness", bdActor, "", "")); err != nil { - return false, 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 - } - time.Sleep(constants.ShutdownNotifyDelay) - - // Inject startup nudge for predecessor discovery via /resume - address := fmt.Sprintf("%s/witness", rigName) - _ = session.StartupNudge(t, sessionName, session.StartupNudgeConfig{ - Recipient: address, - Sender: "deacon", - Topic: "patrol", - }) // Non-fatal - - // GUPP: Gas Town Universal Propulsion Principle - // Send the propulsion nudge to trigger autonomous patrol execution. - // Wait for beacon to be fully processed (needs to be separate prompt) - time.Sleep(2 * time.Second) - _ = t.NudgeSession(sessionName, session.PropulsionNudgeForRole("witness", witnessDir)) // Non-fatal - - return true, nil -} - func runWitnessAttach(cmd *cobra.Command, args []string) error { rigName := "" if len(args) > 0 { @@ -390,8 +281,8 @@ func runWitnessAttach(cmd *cobra.Command, args []string) error { } } - // Verify rig exists - _, r, err := getWitnessManager(rigName) + // Verify rig exists and get manager + mgr, _, err := getWitnessManager(rigName) if err != nil { return err } @@ -399,12 +290,9 @@ func runWitnessAttach(cmd *cobra.Command, args []string) error { sessionName := witnessSessionName(rigName) // Ensure session exists (creates if needed) - created, err := ensureWitnessSession(rigName, r) - if err != nil { + if err := mgr.Start(false); err != nil && err != witness.ErrAlreadyRunning { return err - } - - if created { + } else if err == nil { fmt.Printf("Started witness session for %s\n", rigName) } @@ -424,36 +312,21 @@ func runWitnessAttach(cmd *cobra.Command, args []string) error { func runWitnessRestart(cmd *cobra.Command, args []string) error { rigName := args[0] - mgr, r, err := getWitnessManager(rigName) + mgr, _, err := getWitnessManager(rigName) if err != nil { return err } fmt.Printf("Restarting witness for %s...\n", rigName) - // Kill tmux session if it exists - t := tmux.NewTmux() - sessionName := witnessSessionName(rigName) - running, _ := t.HasSession(sessionName) - if running { - if err := t.KillSession(sessionName); err != nil { - style.PrintWarning("failed to kill session: %v", err) - } - } - - // Update state file to stopped (non-fatal: state file update) + // Stop existing session (non-fatal: may not be running) _ = mgr.Stop() // Start fresh - created, err := ensureWitnessSession(rigName, r) - if err != nil { + if err := mgr.Start(false); err != nil { return fmt.Errorf("starting witness: %w", err) } - if created { - _ = mgr.Start() // non-fatal: state file update - } - fmt.Printf("%s Witness restarted for %s\n", style.Bold.Render("✓"), rigName) fmt.Printf(" %s\n", style.Dim.Render("Use 'gt witness attach' to connect")) return nil diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index fae692c4..f5b872b7 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -21,8 +21,11 @@ import ( "github.com/steveyegge/gastown/internal/deacon" "github.com/steveyegge/gastown/internal/feed" "github.com/steveyegge/gastown/internal/polecat" + "github.com/steveyegge/gastown/internal/refinery" + "github.com/steveyegge/gastown/internal/rig" "github.com/steveyegge/gastown/internal/session" "github.com/steveyegge/gastown/internal/tmux" + "github.com/steveyegge/gastown/internal/witness" ) // Daemon is the town-level background service. @@ -444,53 +447,28 @@ func (d *Daemon) ensureWitnessRunning(rigName string) { } } - // Agent bead check failed or state is not running/working. - // FALLBACK: Check if tmux session is actually healthy before attempting restart. - // This prevents killing healthy sessions when bead state is stale or unreadable. - // Skip this check if agent was marked dead (we already handled that above). - if beadState != "dead" { - hasSession, sessionErr := d.tmux.HasSession(sessionName) - if sessionErr == nil && hasSession { - // Session exists - check if Claude is actually running in it - if d.tmux.IsClaudeRunning(sessionName) { - // Session is healthy - don't restart it - // The bead state may be stale; agent will update it on next activity - d.logger.Printf("Witness for %s session healthy (Claude running), skipping restart despite stale bead", rigName) - return - } - } - } - - // Agent not running (or bead not found) AND session is not healthy - start it + // Agent not running (or bead not found) - use Manager.Start() for unified startup + // Manager.Start() handles: zombie detection, session creation, env vars, theming, + // WaitForClaudeReady, and crucially - startup/propulsion nudges (GUPP) d.logger.Printf("Witness for %s not running per agent bead, starting...", rigName) - // Create session in witness directory - // Use EnsureSessionFresh to handle zombie sessions that exist but have dead Claude - witnessDir := filepath.Join(d.config.TownRoot, rigName, "witness") - if err := d.tmux.EnsureSessionFresh(sessionName, witnessDir); err != nil { - d.logger.Printf("Error creating witness session for %s: %v", rigName, err) + r := &rig.Rig{ + Name: rigName, + Path: filepath.Join(d.config.TownRoot, rigName), + } + mgr := witness.NewManager(r) + + if err := mgr.Start(false); err != nil { + if err == witness.ErrAlreadyRunning { + // Session is healthy (Claude running) - bead state was stale + d.logger.Printf("Witness for %s session healthy (Claude running), skipping restart despite stale bead", rigName) + return + } + d.logger.Printf("Error starting witness for %s: %v", rigName, err) return } - // Set environment - _ = d.tmux.SetEnvironment(sessionName, "GT_ROLE", "witness") - _ = d.tmux.SetEnvironment(sessionName, "GT_RIG", rigName) - _ = d.tmux.SetEnvironment(sessionName, "BD_ACTOR", rigName+"-witness") - - // Launch Claude - bdActor := fmt.Sprintf("%s/witness", rigName) - envVars := map[string]string{ - "GT_ROLE": "witness", - "GT_RIG": rigName, - "BD_ACTOR": bdActor, - "GIT_AUTHOR_NAME": bdActor, - } - if err := d.tmux.SendKeys(sessionName, config.BuildStartupCommand(envVars, "", "")); err != nil { - d.logger.Printf("Error launching Claude in witness session for %s: %v", rigName, err) - return - } - - d.logger.Printf("Witness session for %s started successfully", rigName) + d.logger.Printf("Witness session for %s started successfully (with nudges)", rigName) } // ensureRefineriesRunning ensures refineries are running for all rigs. @@ -531,76 +509,28 @@ func (d *Daemon) ensureRefineryRunning(rigName string) { } } - // Agent bead check failed or state is not running/working. - // FALLBACK: Check if tmux session is actually healthy before attempting restart. - // This prevents killing healthy sessions when bead state is stale or unreadable. - // Skip this check if agent was marked dead (we already handled that above). - if beadState != "dead" { - hasSession, sessionErr := d.tmux.HasSession(sessionName) - if sessionErr == nil && hasSession { - // Session exists - check if Claude is actually running in it - if d.tmux.IsClaudeRunning(sessionName) { - // Session is healthy - don't restart it - // The bead state may be stale; agent will update it on next activity - d.logger.Printf("Refinery for %s session healthy (Claude running), skipping restart despite stale bead", rigName) - return - } - } - } - - // Agent not running (or bead not found) AND session is not healthy - start it + // Agent not running (or bead not found) - use Manager.Start() for unified startup + // Manager.Start() handles: zombie detection, session creation, env vars, theming, + // WaitForClaudeReady, and crucially - startup/propulsion nudges (GUPP) d.logger.Printf("Refinery for %s not running per agent bead, starting...", rigName) - // Determine working directory - rigPath := filepath.Join(d.config.TownRoot, rigName) - refineryDir := filepath.Join(rigPath, "refinery", "rig") - if _, err := os.Stat(refineryDir); os.IsNotExist(err) { - // Fall back to rig path if refinery/rig doesn't exist - refineryDir = rigPath + r := &rig.Rig{ + Name: rigName, + Path: filepath.Join(d.config.TownRoot, rigName), } + mgr := refinery.NewManager(r) - // Create session in refinery directory - // Use EnsureSessionFresh to handle zombie sessions that exist but have dead Claude - if err := d.tmux.EnsureSessionFresh(sessionName, refineryDir); err != nil { - d.logger.Printf("Error creating refinery session for %s: %v", rigName, err) + if err := mgr.Start(false); err != nil { + if err == refinery.ErrAlreadyRunning { + // Session is healthy (Claude running) - bead state was stale + d.logger.Printf("Refinery for %s session healthy (Claude running), skipping restart despite stale bead", rigName) + return + } + d.logger.Printf("Error starting refinery for %s: %v", rigName, err) return } - // Set environment - bdActor := fmt.Sprintf("%s/refinery", rigName) - _ = d.tmux.SetEnvironment(sessionName, "GT_ROLE", "refinery") - _ = d.tmux.SetEnvironment(sessionName, "GT_RIG", rigName) - _ = d.tmux.SetEnvironment(sessionName, "BD_ACTOR", bdActor) - - // Set beads environment - beadsDir := filepath.Join(rigPath, "mayor", "rig", ".beads") - _ = d.tmux.SetEnvironment(sessionName, "BEADS_DIR", beadsDir) - _ = d.tmux.SetEnvironment(sessionName, "BEADS_NO_DAEMON", "1") - _ = d.tmux.SetEnvironment(sessionName, "BEADS_AGENT_NAME", bdActor) - - // Apply theming (non-fatal) - theme := tmux.AssignTheme(rigName) - _ = d.tmux.ConfigureGasTownSession(sessionName, theme, rigName, "refinery", "refinery") - - // Launch Claude with environment exported inline - envVars := map[string]string{ - "GT_ROLE": "refinery", - "GT_RIG": rigName, - "BD_ACTOR": bdActor, - "GIT_AUTHOR_NAME": bdActor, - } - if err := d.tmux.SendKeys(sessionName, config.BuildStartupCommand(envVars, "", "")); err != nil { - d.logger.Printf("Error launching Claude in refinery session for %s: %v", rigName, err) - return - } - - // Wait for Claude to start, then accept bypass permissions warning if it appears. - if err := d.tmux.WaitForCommand(sessionName, constants.SupportedShells, constants.ClaudeStartTimeout); err != nil { - // Non-fatal - Claude might still start - } - _ = d.tmux.AcceptBypassPermissionsWarning(sessionName) - - d.logger.Printf("Refinery session for %s started successfully", rigName) + d.logger.Printf("Refinery session for %s started successfully (with nudges)", rigName) } // getKnownRigs returns list of registered rig names. diff --git a/internal/refinery/manager.go b/internal/refinery/manager.go index 8de5aa62..2199f18f 100644 --- a/internal/refinery/manager.go +++ b/internal/refinery/manager.go @@ -14,10 +14,12 @@ import ( "github.com/steveyegge/gastown/internal/beads" "github.com/steveyegge/gastown/internal/claude" "github.com/steveyegge/gastown/internal/config" + "github.com/steveyegge/gastown/internal/constants" "github.com/steveyegge/gastown/internal/events" "github.com/steveyegge/gastown/internal/mail" "github.com/steveyegge/gastown/internal/mrqueue" "github.com/steveyegge/gastown/internal/rig" + "github.com/steveyegge/gastown/internal/session" "github.com/steveyegge/gastown/internal/tmux" "github.com/steveyegge/gastown/internal/util" ) @@ -209,6 +211,31 @@ func (m *Manager) Start(foreground bool) error { return fmt.Errorf("starting Claude agent: %w", err) } + // Wait for Claude to start and show its prompt (non-fatal) + // WaitForClaudeReady waits for "> " prompt, more reliable than just checking node is running + if err := t.WaitForClaudeReady(sessionID, constants.ClaudeStartTimeout); err != nil { + // Non-fatal - try to continue anyway + } + + // Accept bypass permissions warning dialog if it appears. + _ = t.AcceptBypassPermissionsWarning(sessionID) + + time.Sleep(constants.ShutdownNotifyDelay) + + // Inject startup nudge for predecessor discovery via /resume + address := fmt.Sprintf("%s/refinery", m.rig.Name) + _ = session.StartupNudge(t, sessionID, session.StartupNudgeConfig{ + Recipient: address, + Sender: "deacon", + Topic: "patrol", + }) // Non-fatal + + // GUPP: Gas Town Universal Propulsion Principle + // Send the propulsion nudge to trigger autonomous patrol execution. + // Wait for beacon to be fully processed (needs to be separate prompt) + time.Sleep(2 * time.Second) + _ = t.NudgeSession(sessionID, session.PropulsionNudgeForRole("refinery", refineryRigDir)) // Non-fatal + return nil } diff --git a/internal/witness/manager.go b/internal/witness/manager.go index 77933a04..bff11b3a 100644 --- a/internal/witness/manager.go +++ b/internal/witness/manager.go @@ -2,11 +2,18 @@ package witness import ( "errors" + "fmt" "os" + "path/filepath" "time" "github.com/steveyegge/gastown/internal/agent" + "github.com/steveyegge/gastown/internal/claude" + "github.com/steveyegge/gastown/internal/config" + "github.com/steveyegge/gastown/internal/constants" "github.com/steveyegge/gastown/internal/rig" + "github.com/steveyegge/gastown/internal/session" + "github.com/steveyegge/gastown/internal/tmux" "github.com/steveyegge/gastown/internal/util" ) @@ -67,25 +74,143 @@ func (m *Manager) Status() (*Witness, error) { return w, nil } -// Start starts the witness (marks it as running). -// Patrol logic is now handled by mol-witness-patrol molecule executed by Claude. -func (m *Manager) Start() error { +// sessionName returns the tmux session name for this witness. +func (m *Manager) sessionName() string { + return fmt.Sprintf("gt-%s-witness", m.rig.Name) +} + +// witnessDir returns the working directory for the witness. +// Prefers witness/rig/, falls back to witness/, then rig root. +func (m *Manager) witnessDir() string { + witnessRigDir := filepath.Join(m.rig.Path, "witness", "rig") + if _, err := os.Stat(witnessRigDir); err == nil { + return witnessRigDir + } + + witnessDir := filepath.Join(m.rig.Path, "witness") + if _, err := os.Stat(witnessDir); err == nil { + return witnessDir + } + + return m.rig.Path +} + +// Start starts the witness. +// If foreground is true, only updates state (no tmux session - deprecated). +// Otherwise, spawns a Claude agent in a tmux session. +func (m *Manager) Start(foreground bool) error { w, err := m.loadState() if err != nil { return err } + t := tmux.NewTmux() + sessionID := m.sessionName() + + if foreground { + // Foreground mode is deprecated - patrol logic moved to mol-witness-patrol + if w.State == StateRunning && w.PID > 0 && util.ProcessExists(w.PID) { + return ErrAlreadyRunning + } + + now := time.Now() + w.State = StateRunning + w.StartedAt = &now + w.PID = os.Getpid() + w.MonitoredPolecats = m.rig.Polecats + + return m.saveState(w) + } + + // Background mode: check if session already exists + running, _ := t.HasSession(sessionID) + if running { + // Session exists - check if Claude is actually running (healthy vs zombie) + if t.IsClaudeRunning(sessionID) { + // Healthy - Claude is running + return ErrAlreadyRunning + } + // Zombie - tmux alive but Claude dead. Kill and recreate. + if err := t.KillSession(sessionID); err != nil { + return fmt.Errorf("killing zombie session: %w", err) + } + } + + // Also check via PID for backwards compatibility if w.State == StateRunning && w.PID > 0 && util.ProcessExists(w.PID) { return ErrAlreadyRunning } + // Working directory + witnessDir := m.witnessDir() + + // Ensure Claude settings exist (autonomous role needs mail in SessionStart) + if err := claude.EnsureSettingsForRole(witnessDir, "witness"); err != nil { + 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) + } + + // Set environment variables (non-fatal: session works without these) + bdActor := fmt.Sprintf("%s/witness", m.rig.Name) + _ = t.SetEnvironment(sessionID, "GT_ROLE", "witness") + _ = t.SetEnvironment(sessionID, "GT_RIG", m.rig.Name) + _ = t.SetEnvironment(sessionID, "BD_ACTOR", bdActor) + + // 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 w.StartedAt = &now - w.PID = os.Getpid() + w.PID = 0 // Claude agent doesn't have a PID we track w.MonitoredPolecats = m.rig.Polecats + if err := m.saveState(w); err != nil { + _ = t.KillSession(sessionID) // best-effort cleanup on state save failure + return fmt.Errorf("saving state: %w", err) + } - return m.saveState(w) + // 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 + command := config.BuildAgentStartupCommand("witness", bdActor, "", "") + 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 and show its prompt (non-fatal) + // WaitForClaudeReady waits for "> " prompt, more reliable than just checking node is running + if err := t.WaitForClaudeReady(sessionID, constants.ClaudeStartTimeout); err != nil { + // Non-fatal - try to continue anyway + } + + // Accept bypass permissions warning dialog if it appears. + _ = t.AcceptBypassPermissionsWarning(sessionID) + + time.Sleep(constants.ShutdownNotifyDelay) + + // Inject startup nudge for predecessor discovery via /resume + address := fmt.Sprintf("%s/witness", m.rig.Name) + _ = session.StartupNudge(t, sessionID, session.StartupNudgeConfig{ + Recipient: address, + Sender: "deacon", + Topic: "patrol", + }) // Non-fatal + + // GUPP: Gas Town Universal Propulsion Principle + // Send the propulsion nudge to trigger autonomous patrol execution. + // Wait for beacon to be fully processed (needs to be separate prompt) + time.Sleep(2 * time.Second) + _ = t.NudgeSession(sessionID, session.PropulsionNudgeForRole("witness", witnessDir)) // Non-fatal + + return nil } // Stop stops the witness. @@ -95,12 +220,23 @@ func (m *Manager) Stop() error { return err } - if w.State != StateRunning { + // Check if tmux session exists + t := tmux.NewTmux() + sessionID := m.sessionName() + sessionRunning, _ := t.HasSession(sessionID) + + // If neither state nor session indicates running, it's not running + if w.State != StateRunning && !sessionRunning { return ErrNotRunning } - // If we have a PID, try to stop it gracefully - if w.PID > 0 && w.PID != os.Getpid() { + // Kill tmux session if it exists (best-effort: may already be dead) + if sessionRunning { + _ = t.KillSession(sessionID) + } + + // If we have a PID and it's a different process, try to stop it gracefully + if w.PID > 0 && w.PID != os.Getpid() && util.ProcessExists(w.PID) { // Send SIGTERM (best-effort graceful stop) if proc, err := os.FindProcess(w.PID); err == nil { _ = proc.Signal(os.Interrupt) @@ -112,4 +248,3 @@ func (m *Manager) Stop() error { return m.saveState(w) } -