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 {
return fmt.Errorf("starting witness: %w", err) skipped = append(skipped, "witness (already running)")
} } else {
if created { return fmt.Errorf("starting witness: %w", err)
// Update manager state to reflect running session }
witMgr := witness.NewManager(r) } else {
_ = 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 {
fmt.Printf(" %s Failed to start witness: %v\n", style.Warning.Render("⚠"), err) if err == witness.ErrAlreadyRunning {
hasError = true skipped = append(skipped, "witness")
} else if created { } else {
witMgr := witness.NewManager(r) fmt.Printf(" %s Failed to start witness: %v\n", style.Warning.Render("⚠"), err)
_ = witMgr.Start() hasError = true
}
} else {
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 {
fmt.Printf(" %s Failed to start witness: %v\n", style.Warning.Render("⚠"), err) skipped = append(skipped, "witness")
startErrors = append(startErrors, fmt.Sprintf("witness: %v", err)) } else {
} else if created { fmt.Printf(" %s Failed to start witness: %v\n", style.Warning.Render("⚠"), err)
_ = witMgr.Start() startErrors = append(startErrors, fmt.Sprintf("witness: %v", err))
}
} 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 {
fmt.Printf(" %s %s witness failed: %v\n", style.Dim.Render("○"), r.Name, err) if err == witness.ErrAlreadyRunning {
} else if created { 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) 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 == witness.ErrAlreadyRunning {
if err := mgr.Start(); err != nil { fmt.Printf("%s Witness is already running\n", style.Dim.Render("⚠"))
if err == witness.ErrAlreadyRunning { fmt.Printf(" %s\n", style.Dim.Render("Use 'gt witness attach' to connect"))
fmt.Printf("%s Witness is already running\n", style.Dim.Render("⚠")) 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).
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
d.logger.Printf("Witness for %s not running per agent bead, starting...", rigName) d.logger.Printf("Witness for %s not running per agent bead, starting...", rigName)
// Create session in witness directory r := &rig.Rig{
// Use EnsureSessionFresh to handle zombie sessions that exist but have dead Claude Name: rigName,
witnessDir := filepath.Join(d.config.TownRoot, rigName, "witness") Path: filepath.Join(d.config.TownRoot, rigName),
if err := d.tmux.EnsureSessionFresh(sessionName, witnessDir); err != nil { }
d.logger.Printf("Error creating witness session for %s: %v", rigName, err) 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 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).
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
d.logger.Printf("Refinery for %s not running per agent bead, starting...", rigName) d.logger.Printf("Refinery for %s not running per agent bead, starting...", rigName)
// Determine working directory r := &rig.Rig{
rigPath := filepath.Join(d.config.TownRoot, rigName) Name: rigName,
refineryDir := filepath.Join(rigPath, "refinery", "rig") Path: filepath.Join(d.config.TownRoot, rigName),
if _, err := os.Stat(refineryDir); os.IsNotExist(err) {
// Fall back to rig path if refinery/rig doesn't exist
refineryDir = rigPath
} }
mgr := refinery.NewManager(r)
// Create session in refinery directory if err := mgr.Start(false); err != nil {
// Use EnsureSessionFresh to handle zombie sessions that exist but have dead Claude if err == refinery.ErrAlreadyRunning {
if err := d.tmux.EnsureSessionFresh(sessionName, refineryDir); err != nil { // Session is healthy (Claude running) - bead state was stale
d.logger.Printf("Error creating refinery session for %s: %v", rigName, err) 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 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,25 +74,143 @@ 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) {
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) { if w.State == StateRunning && w.PID > 0 && util.ProcessExists(w.PID) {
return ErrAlreadyRunning 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() now := time.Now()
w.State = StateRunning w.State = StateRunning
w.StartedAt = &now w.StartedAt = &now
w.PID = os.Getpid() w.PID = 0 // Claude agent doesn't have a PID we track
w.MonitoredPolecats = m.rig.Polecats 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. // Stop stops the witness.
@@ -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)
} }