Files
gastown/internal/witness/manager.go
aleiby 31bd120077 fix(startup): unify agent startup with beacon + instructions in CLI prompt (#977)
All agents now receive their startup beacon + role-specific instructions via
the CLI prompt, making sessions identifiable in /resume picker while removing
unreliable post-startup nudges.

Changes:
- Rename FormatStartupNudge → FormatStartupBeacon, StartupNudgeConfig → BeaconConfig
- Remove StartupNudge() function (no longer needed)
- Remove PropulsionNudge() and PropulsionNudgeForRole() functions
- Update deacon, witness, refinery, polecat managers to include beacon in CLI prompt
- Update boot to inline beacon (can't import session due to import cycle)
- Update daemon/lifecycle.go to include beacon via BuildCommandWithPrompt
- Update cmd/deacon.go to include beacon in startup command
- Remove redundant StartupNudge and PropulsionNudge calls from all startup paths

The beacon is now part of the CLI prompt which is queued before Claude starts,
making it more reliable than post-startup nudges which had timing issues.
SessionStart hook runs gt prime automatically, so PropulsionNudge was redundant.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 18:08:57 -08:00

252 lines
8.0 KiB
Go

package witness
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"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/rig"
"github.com/steveyegge/gastown/internal/session"
"github.com/steveyegge/gastown/internal/tmux"
"github.com/steveyegge/gastown/internal/workspace"
)
// Common errors
var (
ErrNotRunning = errors.New("witness not running")
ErrAlreadyRunning = errors.New("witness already running")
)
// Manager handles witness lifecycle and monitoring operations.
// ZFC-compliant: tmux session is the source of truth for running state.
type Manager struct {
rig *rig.Rig
}
// NewManager creates a new witness manager for a rig.
func NewManager(r *rig.Rig) *Manager {
return &Manager{
rig: r,
}
}
// IsRunning checks if the witness session is active.
// ZFC: tmux session existence is the source of truth.
func (m *Manager) IsRunning() (bool, error) {
t := tmux.NewTmux()
return t.HasSession(m.SessionName())
}
// SessionName returns the tmux session name for this witness.
func (m *Manager) SessionName() string {
return fmt.Sprintf("gt-%s-witness", m.rig.Name)
}
// Status returns information about the witness session.
// ZFC-compliant: tmux session is the source of truth.
func (m *Manager) Status() (*tmux.SessionInfo, error) {
t := tmux.NewTmux()
sessionID := m.SessionName()
running, err := t.HasSession(sessionID)
if err != nil {
return nil, fmt.Errorf("checking session: %w", err)
}
if !running {
return nil, ErrNotRunning
}
return t.GetSessionInfo(sessionID)
}
// 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, returns an error (foreground mode deprecated).
// Otherwise, spawns a Claude agent in a tmux session.
// agentOverride optionally specifies a different agent alias to use.
// envOverrides are KEY=VALUE pairs that override all other env var sources.
// ZFC-compliant: no state file, tmux session is source of truth.
func (m *Manager) Start(foreground bool, agentOverride string, envOverrides []string) error {
t := tmux.NewTmux()
sessionID := m.SessionName()
if foreground {
// Foreground mode is deprecated - patrol logic moved to mol-witness-patrol
return fmt.Errorf("foreground mode is deprecated; use background mode (remove --foreground flag)")
}
// 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)
}
}
// Note: No PID check per ZFC - tmux session is the source of truth
// Working directory
witnessDir := m.witnessDir()
// Ensure Claude settings exist in witness/ (not witness/rig/) so we don't
// write into the source repo. Claude walks up the tree to find settings.
witnessParentDir := filepath.Join(m.rig.Path, "witness")
if err := claude.EnsureSettingsForRole(witnessParentDir, "witness"); err != nil {
return fmt.Errorf("ensuring Claude settings: %w", err)
}
roleConfig, err := m.roleConfig()
if err != nil {
return err
}
townRoot := m.townRoot()
// Build startup command first
// 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
// Pass m.rig.Path so rig agent settings are honored (not town-level defaults)
command, err := buildWitnessStartCommand(m.rig.Path, m.rig.Name, townRoot, agentOverride, roleConfig)
if err != nil {
return err
}
// Create session with command directly to avoid send-keys race condition.
// See: https://github.com/anthropics/gastown/issues/280
if err := t.NewSessionWithCommand(sessionID, witnessDir, command); err != nil {
return fmt.Errorf("creating tmux session: %w", err)
}
// Set environment variables (non-fatal: session works without these)
// Use centralized AgentEnv for consistency across all role startup paths
envVars := config.AgentEnv(config.AgentEnvConfig{
Role: "witness",
Rig: m.rig.Name,
TownRoot: townRoot,
})
for k, v := range envVars {
_ = t.SetEnvironment(sessionID, k, v)
}
// Apply role config env vars if present (non-fatal).
for key, value := range roleConfigEnvVars(roleConfig, townRoot, m.rig.Name) {
_ = t.SetEnvironment(sessionID, key, value)
}
// Apply CLI env overrides (highest priority, non-fatal).
for _, override := range envOverrides {
if key, value, ok := strings.Cut(override, "="); ok {
_ = t.SetEnvironment(sessionID, key, value)
}
}
// 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")
// Wait for Claude to start - fatal if Claude fails to launch
if err := t.WaitForCommand(sessionID, constants.SupportedShells, constants.ClaudeStartTimeout); err != nil {
// Kill the zombie session before returning error
_ = t.KillSessionWithProcesses(sessionID)
return fmt.Errorf("waiting for witness to start: %w", err)
}
// Accept bypass permissions warning dialog if it appears.
_ = t.AcceptBypassPermissionsWarning(sessionID)
time.Sleep(constants.ShutdownNotifyDelay)
return nil
}
func (m *Manager) roleConfig() (*beads.RoleConfig, error) {
// Role beads use hq- prefix and live in town-level beads, not rig beads
townRoot := m.townRoot()
bd := beads.NewWithBeadsDir(townRoot, beads.ResolveBeadsDir(townRoot))
roleConfig, err := bd.GetRoleConfig(beads.RoleBeadIDTown("witness"))
if err != nil {
return nil, fmt.Errorf("loading witness role config: %w", err)
}
return roleConfig, nil
}
func (m *Manager) townRoot() string {
townRoot, err := workspace.Find(m.rig.Path)
if err != nil || townRoot == "" {
return m.rig.Path
}
return townRoot
}
func roleConfigEnvVars(roleConfig *beads.RoleConfig, townRoot, rigName string) map[string]string {
if roleConfig == nil || len(roleConfig.EnvVars) == 0 {
return nil
}
expanded := make(map[string]string, len(roleConfig.EnvVars))
for key, value := range roleConfig.EnvVars {
expanded[key] = beads.ExpandRolePattern(value, townRoot, rigName, "", "witness")
}
return expanded
}
func buildWitnessStartCommand(rigPath, rigName, townRoot, agentOverride string, roleConfig *beads.RoleConfig) (string, error) {
if agentOverride != "" {
roleConfig = nil
}
if roleConfig != nil && roleConfig.StartCommand != "" {
return beads.ExpandRolePattern(roleConfig.StartCommand, townRoot, rigName, "", "witness"), nil
}
initialPrompt := session.BuildStartupPrompt(session.BeaconConfig{
Recipient: fmt.Sprintf("%s/witness", rigName),
Sender: "deacon",
Topic: "patrol",
}, "I am Witness for "+rigName+". Start patrol: check gt hook, if empty create mol-witness-patrol wisp and execute it.")
command, err := config.BuildAgentStartupCommandWithAgentOverride("witness", rigName, townRoot, rigPath, initialPrompt, agentOverride)
if err != nil {
return "", fmt.Errorf("building startup command: %w", err)
}
return command, nil
}
// Stop stops the witness.
// ZFC-compliant: tmux session is the source of truth.
func (m *Manager) Stop() error {
t := tmux.NewTmux()
sessionID := m.SessionName()
// Check if tmux session exists
running, _ := t.HasSession(sessionID)
if !running {
return ErrNotRunning
}
// Kill the tmux session
return t.KillSession(sessionID)
}