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

View File

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

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

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

View File

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