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) } -