Unify agent startup with GUPP propulsion nudge

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 <noreply@anthropic.com>
This commit is contained in:
mayor
2026-01-06 01:28:16 -08:00
committed by julianknutsen
parent f6f6acdb2d
commit 31a32c084b
7 changed files with 277 additions and 339 deletions

View File

@@ -737,15 +737,14 @@ func runRigBoot(cmd *cobra.Command, args []string) error {
skipped = append(skipped, "witness (already running)") skipped = append(skipped, "witness (already running)")
} else { } else {
fmt.Printf(" Starting witness...\n") fmt.Printf(" Starting witness...\n")
// Use ensureWitnessSession to create tmux session (same as gt witness start) witMgr := witness.NewManager(r)
created, err := ensureWitnessSession(rigName, r) if err := witMgr.Start(false); err != nil {
if err != nil { if err == witness.ErrAlreadyRunning {
skipped = append(skipped, "witness (already running)")
} else {
return fmt.Errorf("starting witness: %w", err) return fmt.Errorf("starting witness: %w", err)
} }
if created { } else {
// Update manager state to reflect running session
witMgr := witness.NewManager(r)
_ = witMgr.Start() // non-fatal: state file update
started = append(started, "witness") started = append(started, "witness")
} }
} }
@@ -818,13 +817,15 @@ func runRigStart(cmd *cobra.Command, args []string) error {
skipped = append(skipped, "witness") skipped = append(skipped, "witness")
} else { } else {
fmt.Printf(" Starting witness...\n") fmt.Printf(" Starting witness...\n")
created, err := ensureWitnessSession(rigName, r) witMgr := witness.NewManager(r)
if err != nil { 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) fmt.Printf(" %s Failed to start witness: %v\n", style.Warning.Render("⚠"), err)
hasError = true hasError = true
} else if created { }
witMgr := witness.NewManager(r) } else {
_ = witMgr.Start()
started = append(started, "witness") started = append(started, "witness")
} }
} }
@@ -1385,12 +1386,14 @@ func runRigRestart(cmd *cobra.Command, args []string) error {
skipped = append(skipped, "witness") skipped = append(skipped, "witness")
} else { } else {
fmt.Printf(" Starting witness...\n") fmt.Printf(" Starting witness...\n")
created, err := ensureWitnessSession(rigName, r) if err := witMgr.Start(false); err != nil {
if err != nil { if err == witness.ErrAlreadyRunning {
skipped = append(skipped, "witness")
} else {
fmt.Printf(" %s Failed to start witness: %v\n", style.Warning.Render("⚠"), err) fmt.Printf(" %s Failed to start witness: %v\n", style.Warning.Render("⚠"), err)
startErrors = append(startErrors, fmt.Sprintf("witness: %v", err)) startErrors = append(startErrors, fmt.Sprintf("witness: %v", err))
} else if created { }
_ = witMgr.Start() } else {
started = append(started, "witness") started = append(started, "witness")
} }
} }

View File

@@ -19,6 +19,7 @@ import (
"github.com/steveyegge/gastown/internal/session" "github.com/steveyegge/gastown/internal/session"
"github.com/steveyegge/gastown/internal/style" "github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/tmux" "github.com/steveyegge/gastown/internal/tmux"
"github.com/steveyegge/gastown/internal/witness"
"github.com/steveyegge/gastown/internal/workspace" "github.com/steveyegge/gastown/internal/workspace"
) )
@@ -225,10 +226,14 @@ func startRigAgents(t *tmux.Tmux, townRoot string) {
if witnessRunning { if witnessRunning {
fmt.Printf(" %s %s witness already running\n", style.Dim.Render("○"), r.Name) fmt.Printf(" %s %s witness already running\n", style.Dim.Render("○"), r.Name)
} else { } else {
created, err := ensureWitnessSession(r.Name, r) witMgr := witness.NewManager(r)
if err != nil { 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) fmt.Printf(" %s %s witness failed: %v\n", style.Dim.Render("○"), r.Name, err)
} else if created { }
} else {
fmt.Printf(" %s %s witness started\n", style.Bold.Render("✓"), r.Name) fmt.Printf(" %s %s witness started\n", style.Bold.Render("✓"), r.Name)
} }
} }

View File

@@ -10,6 +10,7 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/beads" "github.com/steveyegge/gastown/internal/beads"
"github.com/steveyegge/gastown/internal/claude"
"github.com/steveyegge/gastown/internal/config" "github.com/steveyegge/gastown/internal/config"
"github.com/steveyegge/gastown/internal/constants" "github.com/steveyegge/gastown/internal/constants"
"github.com/steveyegge/gastown/internal/daemon" "github.com/steveyegge/gastown/internal/daemon"
@@ -18,6 +19,7 @@ import (
"github.com/steveyegge/gastown/internal/session" "github.com/steveyegge/gastown/internal/session"
"github.com/steveyegge/gastown/internal/style" "github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/tmux" "github.com/steveyegge/gastown/internal/tmux"
"github.com/steveyegge/gastown/internal/witness"
"github.com/steveyegge/gastown/internal/workspace" "github.com/steveyegge/gastown/internal/workspace"
) )
@@ -103,11 +105,22 @@ func runUp(cmd *cobra.Command, args []string) error {
rigs := discoverRigs(townRoot) rigs := discoverRigs(townRoot)
for _, rigName := range rigs { for _, rigName := range rigs {
sessionName := fmt.Sprintf("gt-%s-witness", rigName) 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()) printStatus(fmt.Sprintf("Witness (%s)", rigName), false, err.Error())
allOK = false 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 { } else {
printStatus(fmt.Sprintf("Witness (%s)", rigName), true, sessionName) printStatus(fmt.Sprintf("Witness (%s)", rigName), true, sessionName)
} }
@@ -244,6 +257,11 @@ func ensureSession(t *tmux.Tmux, sessionName, workDir, role string) error {
return nil return nil
} }
// Ensure Claude settings exist
if err := claude.EnsureSettingsForRole(workDir, role); err != nil {
return fmt.Errorf("ensuring Claude settings: %w", err)
}
// Create session // Create session
if err := t.NewSession(sessionName, workDir); err != nil { if err := t.NewSession(sessionName, workDir); err != nil {
return err return err
@@ -302,59 +320,6 @@ func ensureSession(t *tmux.Tmux, sessionName, workDir, role string) error {
return nil 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. // discoverRigs finds all rigs in the town.
func discoverRigs(townRoot string) []string { func discoverRigs(townRoot string) []string {
var rigs []string var rigs []string

View File

@@ -5,15 +5,9 @@ import (
"fmt" "fmt"
"os" "os"
"os/exec" "os/exec"
"path/filepath"
"time"
"github.com/spf13/cobra" "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/rig"
"github.com/steveyegge/gastown/internal/session"
"github.com/steveyegge/gastown/internal/style" "github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/tmux" "github.com/steveyegge/gastown/internal/tmux"
"github.com/steveyegge/gastown/internal/witness" "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 { func runWitnessStart(cmd *cobra.Command, args []string) error {
rigName := args[0] rigName := args[0]
mgr, r, err := getWitnessManager(rigName) mgr, _, err := getWitnessManager(rigName)
if err != nil { if err != nil {
return err return err
} }
fmt.Printf("Starting witness for %s...\n", rigName) fmt.Printf("Starting witness for %s...\n", rigName)
if witnessForeground { if err := mgr.Start(witnessForeground); err != nil {
// Foreground mode is no longer supported - patrol logic moved to mol-witness-patrol
if err := mgr.Start(); err != nil {
if err == witness.ErrAlreadyRunning { if err == witness.ErrAlreadyRunning {
fmt.Printf("%s Witness is already running\n", style.Dim.Render("⚠")) 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 nil
} }
return fmt.Errorf("starting witness: %w", err) 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 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")) fmt.Printf(" %s\n", style.Dim.Render("Patrol logic is now handled by mol-witness-patrol molecule"))
return nil 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 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 attach' to connect"))
fmt.Printf(" %s\n", style.Dim.Render("Use 'gt witness status' to check progress")) 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) 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 { func runWitnessAttach(cmd *cobra.Command, args []string) error {
rigName := "" rigName := ""
if len(args) > 0 { if len(args) > 0 {
@@ -390,8 +281,8 @@ func runWitnessAttach(cmd *cobra.Command, args []string) error {
} }
} }
// Verify rig exists // Verify rig exists and get manager
_, r, err := getWitnessManager(rigName) mgr, _, err := getWitnessManager(rigName)
if err != nil { if err != nil {
return err return err
} }
@@ -399,12 +290,9 @@ func runWitnessAttach(cmd *cobra.Command, args []string) error {
sessionName := witnessSessionName(rigName) sessionName := witnessSessionName(rigName)
// Ensure session exists (creates if needed) // Ensure session exists (creates if needed)
created, err := ensureWitnessSession(rigName, r) if err := mgr.Start(false); err != nil && err != witness.ErrAlreadyRunning {
if err != nil {
return err return err
} } else if err == nil {
if created {
fmt.Printf("Started witness session for %s\n", rigName) 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 { func runWitnessRestart(cmd *cobra.Command, args []string) error {
rigName := args[0] rigName := args[0]
mgr, r, err := getWitnessManager(rigName) mgr, _, err := getWitnessManager(rigName)
if err != nil { if err != nil {
return err return err
} }
fmt.Printf("Restarting witness for %s...\n", rigName) fmt.Printf("Restarting witness for %s...\n", rigName)
// Kill tmux session if it exists // Stop existing session (non-fatal: may not be running)
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)
_ = mgr.Stop() _ = mgr.Stop()
// Start fresh // Start fresh
created, err := ensureWitnessSession(rigName, r) if err := mgr.Start(false); err != nil {
if err != nil {
return fmt.Errorf("starting witness: %w", err) 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 Witness restarted 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 attach' to connect"))
return nil return nil

View File

@@ -21,8 +21,11 @@ import (
"github.com/steveyegge/gastown/internal/deacon" "github.com/steveyegge/gastown/internal/deacon"
"github.com/steveyegge/gastown/internal/feed" "github.com/steveyegge/gastown/internal/feed"
"github.com/steveyegge/gastown/internal/polecat" "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/session"
"github.com/steveyegge/gastown/internal/tmux" "github.com/steveyegge/gastown/internal/tmux"
"github.com/steveyegge/gastown/internal/witness"
) )
// Daemon is the town-level background service. // 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. // Agent not running (or bead not found) - use Manager.Start() for unified startup
// FALLBACK: Check if tmux session is actually healthy before attempting restart. // Manager.Start() handles: zombie detection, session creation, env vars, theming,
// This prevents killing healthy sessions when bead state is stale or unreadable. // WaitForClaudeReady, and crucially - startup/propulsion nudges (GUPP)
// Skip this check if agent was marked dead (we already handled that above). d.logger.Printf("Witness for %s not running per agent bead, starting...", rigName)
if beadState != "dead" {
hasSession, sessionErr := d.tmux.HasSession(sessionName) r := &rig.Rig{
if sessionErr == nil && hasSession { Name: rigName,
// Session exists - check if Claude is actually running in it Path: filepath.Join(d.config.TownRoot, rigName),
if d.tmux.IsClaudeRunning(sessionName) { }
// Session is healthy - don't restart it mgr := witness.NewManager(r)
// The bead state may be stale; agent will update it on next activity
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) d.logger.Printf("Witness for %s session healthy (Claude running), skipping restart despite stale bead", rigName)
return return
} }
} d.logger.Printf("Error starting witness for %s: %v", rigName, err)
}
// Agent not running (or bead not found) AND session is not healthy - start it
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)
return return
} }
// Set environment d.logger.Printf("Witness session for %s started successfully (with nudges)", rigName)
_ = 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)
} }
// ensureRefineriesRunning ensures refineries are running for all rigs. // 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. // Agent not running (or bead not found) - use Manager.Start() for unified startup
// FALLBACK: Check if tmux session is actually healthy before attempting restart. // Manager.Start() handles: zombie detection, session creation, env vars, theming,
// This prevents killing healthy sessions when bead state is stale or unreadable. // WaitForClaudeReady, and crucially - startup/propulsion nudges (GUPP)
// Skip this check if agent was marked dead (we already handled that above). d.logger.Printf("Refinery for %s not running per agent bead, starting...", rigName)
if beadState != "dead" {
hasSession, sessionErr := d.tmux.HasSession(sessionName) r := &rig.Rig{
if sessionErr == nil && hasSession { Name: rigName,
// Session exists - check if Claude is actually running in it Path: filepath.Join(d.config.TownRoot, rigName),
if d.tmux.IsClaudeRunning(sessionName) { }
// Session is healthy - don't restart it mgr := refinery.NewManager(r)
// The bead state may be stale; agent will update it on next activity
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) d.logger.Printf("Refinery for %s session healthy (Claude running), skipping restart despite stale bead", rigName)
return return
} }
} d.logger.Printf("Error starting refinery for %s: %v", rigName, err)
}
// Agent not running (or bead not found) AND session is not healthy - start it
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
}
// 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)
return return
} }
// Set environment d.logger.Printf("Refinery session for %s started successfully (with nudges)", rigName)
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)
} }
// getKnownRigs returns list of registered rig names. // getKnownRigs returns list of registered rig names.

View File

@@ -14,10 +14,12 @@ import (
"github.com/steveyegge/gastown/internal/beads" "github.com/steveyegge/gastown/internal/beads"
"github.com/steveyegge/gastown/internal/claude" "github.com/steveyegge/gastown/internal/claude"
"github.com/steveyegge/gastown/internal/config" "github.com/steveyegge/gastown/internal/config"
"github.com/steveyegge/gastown/internal/constants"
"github.com/steveyegge/gastown/internal/events" "github.com/steveyegge/gastown/internal/events"
"github.com/steveyegge/gastown/internal/mail" "github.com/steveyegge/gastown/internal/mail"
"github.com/steveyegge/gastown/internal/mrqueue" "github.com/steveyegge/gastown/internal/mrqueue"
"github.com/steveyegge/gastown/internal/rig" "github.com/steveyegge/gastown/internal/rig"
"github.com/steveyegge/gastown/internal/session"
"github.com/steveyegge/gastown/internal/tmux" "github.com/steveyegge/gastown/internal/tmux"
"github.com/steveyegge/gastown/internal/util" "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) 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 return nil
} }

View File

@@ -2,11 +2,18 @@ package witness
import ( import (
"errors" "errors"
"fmt"
"os" "os"
"path/filepath"
"time" "time"
"github.com/steveyegge/gastown/internal/agent" "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/rig"
"github.com/steveyegge/gastown/internal/session"
"github.com/steveyegge/gastown/internal/tmux"
"github.com/steveyegge/gastown/internal/util" "github.com/steveyegge/gastown/internal/util"
) )
@@ -67,14 +74,41 @@ func (m *Manager) Status() (*Witness, error) {
return w, nil return w, nil
} }
// Start starts the witness (marks it as running). // sessionName returns the tmux session name for this witness.
// Patrol logic is now handled by mol-witness-patrol molecule executed by Claude. func (m *Manager) sessionName() string {
func (m *Manager) Start() error { 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() w, err := m.loadState()
if err != nil { if err != nil {
return err 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) { if w.State == StateRunning && w.PID > 0 && util.ProcessExists(w.PID) {
return ErrAlreadyRunning return ErrAlreadyRunning
} }
@@ -88,6 +122,97 @@ func (m *Manager) Start() error {
return m.saveState(w) 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 = 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)
}
// 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. // Stop stops the witness.
func (m *Manager) Stop() error { func (m *Manager) Stop() error {
w, err := m.loadState() w, err := m.loadState()
@@ -95,12 +220,23 @@ func (m *Manager) Stop() error {
return err 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 return ErrNotRunning
} }
// If we have a PID, try to stop it gracefully // Kill tmux session if it exists (best-effort: may already be dead)
if w.PID > 0 && w.PID != os.Getpid() { 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) // Send SIGTERM (best-effort graceful stop)
if proc, err := os.FindProcess(w.PID); err == nil { if proc, err := os.FindProcess(w.PID); err == nil {
_ = proc.Signal(os.Interrupt) _ = proc.Signal(os.Interrupt)
@@ -112,4 +248,3 @@ func (m *Manager) Stop() error {
return m.saveState(w) return m.saveState(w)
} }