fix: Make Mayor/Deacon session names include town name
Session names `gt-mayor` and `gt-deacon` were hardcoded, causing tmux
session name collisions when running multiple towns simultaneously.
Changed to `gt-{town}-mayor` and `gt-{town}-deacon` format (e.g.,
`gt-ai-mayor`) to allow concurrent multi-town operation.
Key changes:
- session.MayorSessionName() and DeaconSessionName() now take townName param
- Added workspace.GetTownName() helper to load town name from config
- Updated all callers in cmd/, daemon/, doctor/, mail/, rig/, templates/
- Updated tests with new session name format
- Bead IDs remain unchanged (already scoped by .beads/ directory)
Fixes #60
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
+4035
-1314
File diff suppressed because one or more lines are too long
@@ -35,6 +35,7 @@ type AgentSession struct {
|
|||||||
Type AgentType
|
Type AgentType
|
||||||
Rig string // For rig-specific agents
|
Rig string // For rig-specific agents
|
||||||
AgentName string // e.g., crew name, polecat name
|
AgentName string // e.g., crew name, polecat name
|
||||||
|
Town string // For mayor/deacon only (town name from session)
|
||||||
}
|
}
|
||||||
|
|
||||||
// AgentTypeColors maps agent types to tmux color codes.
|
// AgentTypeColors maps agent types to tmux color codes.
|
||||||
@@ -135,13 +136,16 @@ func categorizeSession(name string) *AgentSession {
|
|||||||
session := &AgentSession{Name: name}
|
session := &AgentSession{Name: name}
|
||||||
suffix := strings.TrimPrefix(name, "gt-")
|
suffix := strings.TrimPrefix(name, "gt-")
|
||||||
|
|
||||||
// Town-level agents
|
// Town-level agents: gt-{town}-mayor, gt-{town}-deacon
|
||||||
if suffix == "mayor" {
|
// Check if suffix ends with -mayor or -deacon (new format)
|
||||||
|
if strings.HasSuffix(suffix, "-mayor") {
|
||||||
session.Type = AgentMayor
|
session.Type = AgentMayor
|
||||||
|
session.Town = strings.TrimSuffix(suffix, "-mayor")
|
||||||
return session
|
return session
|
||||||
}
|
}
|
||||||
if suffix == "deacon" {
|
if strings.HasSuffix(suffix, "-deacon") {
|
||||||
session.Type = AgentDeacon
|
session.Type = AgentDeacon
|
||||||
|
session.Town = strings.TrimSuffix(suffix, "-deacon")
|
||||||
return session
|
return session
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -277,7 +277,10 @@ func runDegradedTriage(b *boot.Boot) (action, target string, err error) {
|
|||||||
tm := b.Tmux()
|
tm := b.Tmux()
|
||||||
|
|
||||||
// Check if Deacon session exists
|
// Check if Deacon session exists
|
||||||
deaconSession := "gt-deacon"
|
deaconSession, err := getDeaconSessionName()
|
||||||
|
if err != nil {
|
||||||
|
return "error", "deacon", fmt.Errorf("getting deacon session name: %w", err)
|
||||||
|
}
|
||||||
hasDeacon, err := tm.HasSession(deaconSession)
|
hasDeacon, err := tm.HasSession(deaconSession)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "error", "deacon", fmt.Errorf("checking deacon session: %w", err)
|
return "error", "deacon", fmt.Errorf("checking deacon session: %w", err)
|
||||||
|
|||||||
@@ -618,12 +618,13 @@ func runCostsRecord(cmd *cobra.Command, args []string) error {
|
|||||||
// - Polecats: gt-{rig}-{polecat} (e.g., gt-gastown-toast)
|
// - Polecats: gt-{rig}-{polecat} (e.g., gt-gastown-toast)
|
||||||
// - Crew: gt-{rig}-crew-{crew} (e.g., gt-gastown-crew-max)
|
// - Crew: gt-{rig}-crew-{crew} (e.g., gt-gastown-crew-max)
|
||||||
// - Witness/Refinery: gt-{rig}-{role} (e.g., gt-gastown-witness)
|
// - Witness/Refinery: gt-{rig}-{role} (e.g., gt-gastown-witness)
|
||||||
// - Mayor/Deacon: gt-{role} (e.g., gt-mayor)
|
// - Mayor/Deacon: gt-{town}-{role} (e.g., gt-ai-mayor)
|
||||||
func deriveSessionName() string {
|
func deriveSessionName() string {
|
||||||
role := os.Getenv("GT_ROLE")
|
role := os.Getenv("GT_ROLE")
|
||||||
rig := os.Getenv("GT_RIG")
|
rig := os.Getenv("GT_RIG")
|
||||||
polecat := os.Getenv("GT_POLECAT")
|
polecat := os.Getenv("GT_POLECAT")
|
||||||
crew := os.Getenv("GT_CREW")
|
crew := os.Getenv("GT_CREW")
|
||||||
|
town := os.Getenv("GT_TOWN")
|
||||||
|
|
||||||
// Polecat: gt-{rig}-{polecat}
|
// Polecat: gt-{rig}-{polecat}
|
||||||
if polecat != "" && rig != "" {
|
if polecat != "" && rig != "" {
|
||||||
@@ -635,9 +636,9 @@ func deriveSessionName() string {
|
|||||||
return fmt.Sprintf("gt-%s-crew-%s", rig, crew)
|
return fmt.Sprintf("gt-%s-crew-%s", rig, crew)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Global roles without rig: gt-{role}
|
// Town-level roles (mayor, deacon): gt-{town}-{role}
|
||||||
if role != "" && rig == "" {
|
if (role == "mayor" || role == "deacon") && town != "" {
|
||||||
return fmt.Sprintf("gt-%s", role)
|
return fmt.Sprintf("gt-%s-%s", town, role)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rig-based roles (witness, refinery): gt-{rig}-{role}
|
// Rig-based roles (witness, refinery): gt-{rig}-{role}
|
||||||
|
|||||||
@@ -49,15 +49,17 @@ func TestDeriveSessionName(t *testing.T) {
|
|||||||
name: "mayor session",
|
name: "mayor session",
|
||||||
envVars: map[string]string{
|
envVars: map[string]string{
|
||||||
"GT_ROLE": "mayor",
|
"GT_ROLE": "mayor",
|
||||||
|
"GT_TOWN": "ai",
|
||||||
},
|
},
|
||||||
expected: "gt-mayor",
|
expected: "gt-ai-mayor",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "deacon session",
|
name: "deacon session",
|
||||||
envVars: map[string]string{
|
envVars: map[string]string{
|
||||||
"GT_ROLE": "deacon",
|
"GT_ROLE": "deacon",
|
||||||
|
"GT_TOWN": "ai",
|
||||||
},
|
},
|
||||||
expected: "gt-deacon",
|
expected: "gt-ai-deacon",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "no env vars",
|
name: "no env vars",
|
||||||
@@ -70,7 +72,7 @@ func TestDeriveSessionName(t *testing.T) {
|
|||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
// Save and clear relevant env vars
|
// Save and clear relevant env vars
|
||||||
saved := make(map[string]string)
|
saved := make(map[string]string)
|
||||||
envKeys := []string{"GT_ROLE", "GT_RIG", "GT_POLECAT", "GT_CREW"}
|
envKeys := []string{"GT_ROLE", "GT_RIG", "GT_POLECAT", "GT_CREW", "GT_TOWN"}
|
||||||
for _, key := range envKeys {
|
for _, key := range envKeys {
|
||||||
saved[key] = os.Getenv(key)
|
saved[key] = os.Getenv(key)
|
||||||
os.Unsetenv(key)
|
os.Unsetenv(key)
|
||||||
|
|||||||
@@ -77,9 +77,12 @@ func cycleToSession(direction int, sessionOverride string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if it's a town-level session
|
// Check if it's a town-level session
|
||||||
for _, townSession := range townLevelSessions {
|
townLevelSessions := getTownLevelSessions()
|
||||||
if session == townSession {
|
if townLevelSessions != nil {
|
||||||
return cycleTownSession(direction, session)
|
for _, townSession := range townLevelSessions {
|
||||||
|
if session == townSession {
|
||||||
|
return cycleTownSession(direction, session)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+84
-29
@@ -21,8 +21,19 @@ import (
|
|||||||
"github.com/steveyegge/gastown/internal/workspace"
|
"github.com/steveyegge/gastown/internal/workspace"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DeaconSessionName is the tmux session name for the Deacon.
|
// getDeaconSessionName returns the Deacon session name for the current workspace.
|
||||||
const DeaconSessionName = "gt-deacon"
|
// The session name includes the town name to avoid collisions between multiple HQs.
|
||||||
|
func getDeaconSessionName() (string, error) {
|
||||||
|
townRoot, err := workspace.FindFromCwdOrError()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("not in a Gas Town workspace: %w", err)
|
||||||
|
}
|
||||||
|
townName, err := workspace.GetTownName(townRoot)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return session.DeaconSessionName(townName), nil
|
||||||
|
}
|
||||||
|
|
||||||
var deaconCmd = &cobra.Command{
|
var deaconCmd = &cobra.Command{
|
||||||
Use: "deacon",
|
Use: "deacon",
|
||||||
@@ -274,8 +285,13 @@ func init() {
|
|||||||
func runDeaconStart(cmd *cobra.Command, args []string) error {
|
func runDeaconStart(cmd *cobra.Command, args []string) error {
|
||||||
t := tmux.NewTmux()
|
t := tmux.NewTmux()
|
||||||
|
|
||||||
|
sessionName, err := getDeaconSessionName()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// Check if session already exists
|
// Check if session already exists
|
||||||
running, err := t.HasSession(DeaconSessionName)
|
running, err := t.HasSession(sessionName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("checking session: %w", err)
|
return fmt.Errorf("checking session: %w", err)
|
||||||
}
|
}
|
||||||
@@ -283,7 +299,7 @@ func runDeaconStart(cmd *cobra.Command, args []string) error {
|
|||||||
return fmt.Errorf("Deacon session already running. Attach with: gt deacon attach")
|
return fmt.Errorf("Deacon session already running. Attach with: gt deacon attach")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := startDeaconSession(t); err != nil {
|
if err := startDeaconSession(t, sessionName); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,7 +311,7 @@ func runDeaconStart(cmd *cobra.Command, args []string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// startDeaconSession creates and initializes the Deacon tmux session.
|
// startDeaconSession creates and initializes the Deacon tmux session.
|
||||||
func startDeaconSession(t *tmux.Tmux) error {
|
func startDeaconSession(t *tmux.Tmux, sessionName string) error {
|
||||||
// Find workspace root
|
// Find workspace root
|
||||||
townRoot, err := workspace.FindFromCwdOrError()
|
townRoot, err := workspace.FindFromCwdOrError()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -317,35 +333,35 @@ func startDeaconSession(t *tmux.Tmux) error {
|
|||||||
|
|
||||||
// Create session in deacon directory
|
// Create session in deacon directory
|
||||||
fmt.Println("Starting Deacon session...")
|
fmt.Println("Starting Deacon session...")
|
||||||
if err := t.NewSession(DeaconSessionName, deaconDir); err != nil {
|
if err := t.NewSession(sessionName, deaconDir); err != nil {
|
||||||
return fmt.Errorf("creating session: %w", err)
|
return fmt.Errorf("creating session: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set environment (non-fatal: session works without these)
|
// Set environment (non-fatal: session works without these)
|
||||||
_ = t.SetEnvironment(DeaconSessionName, "GT_ROLE", "deacon")
|
_ = t.SetEnvironment(sessionName, "GT_ROLE", "deacon")
|
||||||
_ = t.SetEnvironment(DeaconSessionName, "BD_ACTOR", "deacon")
|
_ = t.SetEnvironment(sessionName, "BD_ACTOR", "deacon")
|
||||||
|
|
||||||
// Apply Deacon theme (non-fatal: theming failure doesn't affect operation)
|
// Apply Deacon theme (non-fatal: theming failure doesn't affect operation)
|
||||||
// Note: ConfigureGasTownSession includes cycle bindings
|
// Note: ConfigureGasTownSession includes cycle bindings
|
||||||
theme := tmux.DeaconTheme()
|
theme := tmux.DeaconTheme()
|
||||||
_ = t.ConfigureGasTownSession(DeaconSessionName, theme, "", "Deacon", "health-check")
|
_ = t.ConfigureGasTownSession(sessionName, theme, "", "Deacon", "health-check")
|
||||||
|
|
||||||
// Launch Claude directly (no shell respawn loop)
|
// Launch Claude directly (no shell respawn loop)
|
||||||
// Restarts are handled by daemon via ensureDeaconRunning on each heartbeat
|
// Restarts are handled by daemon via ensureDeaconRunning on each heartbeat
|
||||||
// The startup hook handles context loading automatically
|
// The startup hook handles context loading automatically
|
||||||
// Export GT_ROLE and BD_ACTOR in the command since tmux SetEnvironment only affects new panes
|
// Export GT_ROLE and BD_ACTOR in the command since tmux SetEnvironment only affects new panes
|
||||||
if err := t.SendKeys(DeaconSessionName, config.BuildAgentStartupCommand("deacon", "deacon", "", "")); err != nil {
|
if err := t.SendKeys(sessionName, config.BuildAgentStartupCommand("deacon", "deacon", "", "")); err != nil {
|
||||||
return fmt.Errorf("sending command: %w", err)
|
return fmt.Errorf("sending command: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for Claude to start (non-fatal)
|
// Wait for Claude to start (non-fatal)
|
||||||
if err := t.WaitForCommand(DeaconSessionName, constants.SupportedShells, constants.ClaudeStartTimeout); err != nil {
|
if err := t.WaitForCommand(sessionName, constants.SupportedShells, constants.ClaudeStartTimeout); err != nil {
|
||||||
// Non-fatal
|
// Non-fatal
|
||||||
}
|
}
|
||||||
time.Sleep(constants.ShutdownNotifyDelay)
|
time.Sleep(constants.ShutdownNotifyDelay)
|
||||||
|
|
||||||
// Inject startup nudge for predecessor discovery via /resume
|
// Inject startup nudge for predecessor discovery via /resume
|
||||||
_ = session.StartupNudge(t, DeaconSessionName, session.StartupNudgeConfig{
|
_ = session.StartupNudge(t, sessionName, session.StartupNudgeConfig{
|
||||||
Recipient: "deacon",
|
Recipient: "deacon",
|
||||||
Sender: "daemon",
|
Sender: "daemon",
|
||||||
Topic: "patrol",
|
Topic: "patrol",
|
||||||
@@ -355,7 +371,7 @@ func startDeaconSession(t *tmux.Tmux) error {
|
|||||||
// Send the propulsion nudge to trigger autonomous patrol execution.
|
// Send the propulsion nudge to trigger autonomous patrol execution.
|
||||||
// Wait for beacon to be fully processed (needs to be separate prompt)
|
// Wait for beacon to be fully processed (needs to be separate prompt)
|
||||||
time.Sleep(2 * time.Second)
|
time.Sleep(2 * time.Second)
|
||||||
_ = t.NudgeSession(DeaconSessionName, session.PropulsionNudgeForRole("deacon", deaconDir)) // Non-fatal
|
_ = t.NudgeSession(sessionName, session.PropulsionNudgeForRole("deacon", deaconDir)) // Non-fatal
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -363,8 +379,13 @@ func startDeaconSession(t *tmux.Tmux) error {
|
|||||||
func runDeaconStop(cmd *cobra.Command, args []string) error {
|
func runDeaconStop(cmd *cobra.Command, args []string) error {
|
||||||
t := tmux.NewTmux()
|
t := tmux.NewTmux()
|
||||||
|
|
||||||
|
sessionName, err := getDeaconSessionName()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// Check if session exists
|
// Check if session exists
|
||||||
running, err := t.HasSession(DeaconSessionName)
|
running, err := t.HasSession(sessionName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("checking session: %w", err)
|
return fmt.Errorf("checking session: %w", err)
|
||||||
}
|
}
|
||||||
@@ -375,11 +396,11 @@ func runDeaconStop(cmd *cobra.Command, args []string) error {
|
|||||||
fmt.Println("Stopping Deacon session...")
|
fmt.Println("Stopping Deacon session...")
|
||||||
|
|
||||||
// Try graceful shutdown first (best-effort interrupt)
|
// Try graceful shutdown first (best-effort interrupt)
|
||||||
_ = t.SendKeysRaw(DeaconSessionName, "C-c")
|
_ = t.SendKeysRaw(sessionName, "C-c")
|
||||||
time.Sleep(100 * time.Millisecond)
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
// Kill the session
|
// Kill the session
|
||||||
if err := t.KillSession(DeaconSessionName); err != nil {
|
if err := t.KillSession(sessionName); err != nil {
|
||||||
return fmt.Errorf("killing session: %w", err)
|
return fmt.Errorf("killing session: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -390,35 +411,45 @@ func runDeaconStop(cmd *cobra.Command, args []string) error {
|
|||||||
func runDeaconAttach(cmd *cobra.Command, args []string) error {
|
func runDeaconAttach(cmd *cobra.Command, args []string) error {
|
||||||
t := tmux.NewTmux()
|
t := tmux.NewTmux()
|
||||||
|
|
||||||
|
sessionName, err := getDeaconSessionName()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// Check if session exists
|
// Check if session exists
|
||||||
running, err := t.HasSession(DeaconSessionName)
|
running, err := t.HasSession(sessionName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("checking session: %w", err)
|
return fmt.Errorf("checking session: %w", err)
|
||||||
}
|
}
|
||||||
if !running {
|
if !running {
|
||||||
// Auto-start if not running
|
// Auto-start if not running
|
||||||
fmt.Println("Deacon session not running, starting...")
|
fmt.Println("Deacon session not running, starting...")
|
||||||
if err := startDeaconSession(t); err != nil {
|
if err := startDeaconSession(t, sessionName); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Session uses a respawn loop, so Claude restarts automatically if it exits
|
// Session uses a respawn loop, so Claude restarts automatically if it exits
|
||||||
|
|
||||||
// Use shared attach helper (smart: links if inside tmux, attaches if outside)
|
// Use shared attach helper (smart: links if inside tmux, attaches if outside)
|
||||||
return attachToTmuxSession(DeaconSessionName)
|
return attachToTmuxSession(sessionName)
|
||||||
}
|
}
|
||||||
|
|
||||||
func runDeaconStatus(cmd *cobra.Command, args []string) error {
|
func runDeaconStatus(cmd *cobra.Command, args []string) error {
|
||||||
t := tmux.NewTmux()
|
t := tmux.NewTmux()
|
||||||
|
|
||||||
running, err := t.HasSession(DeaconSessionName)
|
sessionName, err := getDeaconSessionName()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
running, err := t.HasSession(sessionName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("checking session: %w", err)
|
return fmt.Errorf("checking session: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if running {
|
if running {
|
||||||
// Get session info for more details
|
// Get session info for more details
|
||||||
info, err := t.GetSessionInfo(DeaconSessionName)
|
info, err := t.GetSessionInfo(sessionName)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
status := "detached"
|
status := "detached"
|
||||||
if info.Attached {
|
if info.Attached {
|
||||||
@@ -448,7 +479,12 @@ func runDeaconStatus(cmd *cobra.Command, args []string) error {
|
|||||||
func runDeaconRestart(cmd *cobra.Command, args []string) error {
|
func runDeaconRestart(cmd *cobra.Command, args []string) error {
|
||||||
t := tmux.NewTmux()
|
t := tmux.NewTmux()
|
||||||
|
|
||||||
running, err := t.HasSession(DeaconSessionName)
|
sessionName, err := getDeaconSessionName()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
running, err := t.HasSession(sessionName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("checking session: %w", err)
|
return fmt.Errorf("checking session: %w", err)
|
||||||
}
|
}
|
||||||
@@ -457,7 +493,7 @@ func runDeaconRestart(cmd *cobra.Command, args []string) error {
|
|||||||
|
|
||||||
if running {
|
if running {
|
||||||
// Kill existing session
|
// Kill existing session
|
||||||
if err := t.KillSession(DeaconSessionName); err != nil {
|
if err := t.KillSession(sessionName); err != nil {
|
||||||
style.PrintWarning("failed to kill session: %v", err)
|
style.PrintWarning("failed to kill session: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -637,8 +673,14 @@ func runDeaconHealthCheck(cmd *cobra.Command, args []string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get town name for session name generation
|
||||||
|
townName, err := workspace.GetTownName(townRoot)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("getting town name: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Get agent bead info before ping (for baseline)
|
// Get agent bead info before ping (for baseline)
|
||||||
beadID, sessionName, err := agentAddressToIDs(agent)
|
beadID, sessionName, err := agentAddressToIDs(agent, townName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("invalid agent address: %w", err)
|
return fmt.Errorf("invalid agent address: %w", err)
|
||||||
}
|
}
|
||||||
@@ -745,8 +787,14 @@ func runDeaconForceKill(cmd *cobra.Command, args []string) error {
|
|||||||
agent, remaining.Round(time.Second))
|
agent, remaining.Round(time.Second))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get town name for session name generation
|
||||||
|
townName, err := workspace.GetTownName(townRoot)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("getting town name: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Get session name
|
// Get session name
|
||||||
_, sessionName, err := agentAddressToIDs(agent)
|
_, sessionName, err := agentAddressToIDs(agent, townName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("invalid agent address: %w", err)
|
return fmt.Errorf("invalid agent address: %w", err)
|
||||||
}
|
}
|
||||||
@@ -1106,12 +1154,15 @@ func notifyMayorOfWitnessFailure(townRoot string, zombies []zombieInfo) {
|
|||||||
|
|
||||||
// agentAddressToIDs converts an agent address to bead ID and session name.
|
// agentAddressToIDs converts an agent address to bead ID and session name.
|
||||||
// Supports formats: "gastown/polecats/max", "gastown/witness", "deacon", "mayor"
|
// Supports formats: "gastown/polecats/max", "gastown/witness", "deacon", "mayor"
|
||||||
func agentAddressToIDs(address string) (beadID, sessionName string, err error) {
|
// The townName parameter is required for mayor/deacon to generate correct session names.
|
||||||
|
func agentAddressToIDs(address, townName string) (beadID, sessionName string, err error) {
|
||||||
switch address {
|
switch address {
|
||||||
case "deacon":
|
case "deacon":
|
||||||
return "gt-deacon", DeaconSessionName, nil
|
sessName := session.DeaconSessionName(townName)
|
||||||
|
return sessName, sessName, nil
|
||||||
case "mayor":
|
case "mayor":
|
||||||
return "gt-mayor", "gt-mayor", nil
|
sessName := session.MayorSessionName(townName)
|
||||||
|
return sessName, sessName, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
parts := strings.Split(address, "/")
|
parts := strings.Split(address, "/")
|
||||||
@@ -1176,7 +1227,11 @@ func sendMail(townRoot, to, subject, body string) {
|
|||||||
|
|
||||||
// updateAgentBeadState updates an agent bead's state.
|
// updateAgentBeadState updates an agent bead's state.
|
||||||
func updateAgentBeadState(townRoot, agent, state, reason string) {
|
func updateAgentBeadState(townRoot, agent, state, reason string) {
|
||||||
beadID, _, err := agentAddressToIDs(agent)
|
townName, err := workspace.GetTownName(townRoot)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
beadID, _, err := agentAddressToIDs(agent, townName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,13 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestAddressToAgentBeadID(t *testing.T) {
|
func TestAddressToAgentBeadID(t *testing.T) {
|
||||||
|
townName := "ai"
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
address string
|
address string
|
||||||
expected string
|
expected string
|
||||||
}{
|
}{
|
||||||
{"mayor", "gt-mayor"},
|
{"mayor", "gt-ai-mayor"},
|
||||||
{"deacon", "gt-deacon"},
|
{"deacon", "gt-ai-deacon"},
|
||||||
{"gastown/witness", "gt-gastown-witness"},
|
{"gastown/witness", "gt-gastown-witness"},
|
||||||
{"gastown/refinery", "gt-gastown-refinery"},
|
{"gastown/refinery", "gt-gastown-refinery"},
|
||||||
{"gastown/alpha", "gt-gastown-polecat-alpha"},
|
{"gastown/alpha", "gt-gastown-polecat-alpha"},
|
||||||
@@ -24,9 +25,9 @@ func TestAddressToAgentBeadID(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.address, func(t *testing.T) {
|
t.Run(tt.address, func(t *testing.T) {
|
||||||
got := addressToAgentBeadID(tt.address)
|
got := addressToAgentBeadID(tt.address, townName)
|
||||||
if got != tt.expected {
|
if got != tt.expected {
|
||||||
t.Errorf("addressToAgentBeadID(%q) = %q, want %q", tt.address, got, tt.expected)
|
t.Errorf("addressToAgentBeadID(%q, %q) = %q, want %q", tt.address, townName, got, tt.expected)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-4
@@ -483,10 +483,13 @@ func showDogStatus(mgr *dog.Manager, name string) error {
|
|||||||
// Check for tmux session
|
// Check for tmux session
|
||||||
townRoot, _ := workspace.FindFromCwd()
|
townRoot, _ := workspace.FindFromCwd()
|
||||||
if townRoot != "" {
|
if townRoot != "" {
|
||||||
sessionName := fmt.Sprintf("gt-deacon-%s", name)
|
townName, err := workspace.GetTownName(townRoot)
|
||||||
tm := tmux.NewTmux()
|
if err == nil {
|
||||||
if has, _ := tm.HasSession(sessionName); has {
|
sessionName := fmt.Sprintf("gt-%s-deacon-%s", townName, name)
|
||||||
fmt.Printf("\nSession: %s (running)\n", sessionName)
|
tm := tmux.NewTmux()
|
||||||
|
if has, _ := tm.HasSession(sessionName); has {
|
||||||
|
fmt.Printf("\nSession: %s (running)\n", sessionName)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -73,8 +73,12 @@ func runDown(cmd *cobra.Command, args []string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get session names
|
||||||
|
mayorSession, _ := getMayorSessionName()
|
||||||
|
deaconSession, _ := getDeaconSessionName()
|
||||||
|
|
||||||
// 2. Stop Mayor
|
// 2. Stop Mayor
|
||||||
if err := stopSession(t, MayorSessionName); err != nil {
|
if err := stopSession(t, mayorSession); err != nil {
|
||||||
printDownStatus("Mayor", false, err.Error())
|
printDownStatus("Mayor", false, err.Error())
|
||||||
allOK = false
|
allOK = false
|
||||||
} else {
|
} else {
|
||||||
@@ -90,7 +94,7 @@ func runDown(cmd *cobra.Command, args []string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 4. Stop Deacon
|
// 4. Stop Deacon
|
||||||
if err := stopSession(t, DeaconSessionName); err != nil {
|
if err := stopSession(t, deaconSession); err != nil {
|
||||||
printDownStatus("Deacon", false, err.Error())
|
printDownStatus("Deacon", false, err.Error())
|
||||||
allOK = false
|
allOK = false
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+16
-4
@@ -230,10 +230,18 @@ func resolveRoleToSession(role string) (string, error) {
|
|||||||
|
|
||||||
switch strings.ToLower(role) {
|
switch strings.ToLower(role) {
|
||||||
case "mayor", "may":
|
case "mayor", "may":
|
||||||
return "gt-mayor", nil
|
mayorSession, err := getMayorSessionName()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("cannot determine mayor session name: %w", err)
|
||||||
|
}
|
||||||
|
return mayorSession, nil
|
||||||
|
|
||||||
case "deacon", "dea":
|
case "deacon", "dea":
|
||||||
return "gt-deacon", nil
|
deaconSession, err := getDeaconSessionName()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("cannot determine deacon session name: %w", err)
|
||||||
|
}
|
||||||
|
return deaconSession, nil
|
||||||
|
|
||||||
case "crew":
|
case "crew":
|
||||||
// Try to get rig and crew name from environment or cwd
|
// Try to get rig and crew name from environment or cwd
|
||||||
@@ -360,11 +368,15 @@ func buildRestartCommand(sessionName string) (string, error) {
|
|||||||
// sessionWorkDir returns the correct working directory for a session.
|
// sessionWorkDir returns the correct working directory for a session.
|
||||||
// This is the canonical home for each role type.
|
// This is the canonical home for each role type.
|
||||||
func sessionWorkDir(sessionName, townRoot string) (string, error) {
|
func sessionWorkDir(sessionName, townRoot string) (string, error) {
|
||||||
|
// Get session names for comparison
|
||||||
|
mayorSession, _ := getMayorSessionName()
|
||||||
|
deaconSession, _ := getDeaconSessionName()
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case sessionName == "gt-mayor":
|
case sessionName == mayorSession:
|
||||||
return townRoot, nil
|
return townRoot, nil
|
||||||
|
|
||||||
case sessionName == "gt-deacon":
|
case sessionName == deaconSession:
|
||||||
return townRoot + "/deacon", nil
|
return townRoot + "/deacon", nil
|
||||||
|
|
||||||
case strings.Contains(sessionName, "-crew-"):
|
case strings.Contains(sessionName, "-crew-"):
|
||||||
|
|||||||
+10
-3
@@ -12,6 +12,7 @@ import (
|
|||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/steveyegge/gastown/internal/config"
|
"github.com/steveyegge/gastown/internal/config"
|
||||||
"github.com/steveyegge/gastown/internal/deps"
|
"github.com/steveyegge/gastown/internal/deps"
|
||||||
|
"github.com/steveyegge/gastown/internal/session"
|
||||||
"github.com/steveyegge/gastown/internal/style"
|
"github.com/steveyegge/gastown/internal/style"
|
||||||
"github.com/steveyegge/gastown/internal/templates"
|
"github.com/steveyegge/gastown/internal/templates"
|
||||||
"github.com/steveyegge/gastown/internal/workspace"
|
"github.com/steveyegge/gastown/internal/workspace"
|
||||||
@@ -252,10 +253,16 @@ func createMayorCLAUDEmd(hqRoot, townRoot string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get town name for session names
|
||||||
|
townName, _ := workspace.GetTownName(townRoot)
|
||||||
|
|
||||||
data := templates.RoleData{
|
data := templates.RoleData{
|
||||||
Role: "mayor",
|
Role: "mayor",
|
||||||
TownRoot: townRoot,
|
TownRoot: townRoot,
|
||||||
WorkDir: hqRoot,
|
TownName: townName,
|
||||||
|
WorkDir: hqRoot,
|
||||||
|
MayorSession: session.MayorSessionName(townName),
|
||||||
|
DeaconSession: session.DeaconSessionName(townName),
|
||||||
}
|
}
|
||||||
|
|
||||||
content, err := tmpl.RenderRole("mayor", data)
|
content, err := tmpl.RenderRole("mayor", data)
|
||||||
|
|||||||
@@ -122,7 +122,10 @@ func detectCurrentSession() string {
|
|||||||
|
|
||||||
// Check if we're mayor
|
// Check if we're mayor
|
||||||
if os.Getenv("GT_ROLE") == "mayor" {
|
if os.Getenv("GT_ROLE") == "mayor" {
|
||||||
return "gt-mayor"
|
mayorSession, err := getMayorSessionName()
|
||||||
|
if err == nil {
|
||||||
|
return mayorSession
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
+60
-24
@@ -14,8 +14,19 @@ import (
|
|||||||
"github.com/steveyegge/gastown/internal/workspace"
|
"github.com/steveyegge/gastown/internal/workspace"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MayorSessionName is the tmux session name for the Mayor.
|
// getMayorSessionName returns the Mayor session name for the current workspace.
|
||||||
const MayorSessionName = "gt-mayor"
|
// The session name includes the town name to avoid collisions between multiple HQs.
|
||||||
|
func getMayorSessionName() (string, error) {
|
||||||
|
townRoot, err := workspace.FindFromCwdOrError()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("not in a Gas Town workspace: %w", err)
|
||||||
|
}
|
||||||
|
townName, err := workspace.GetTownName(townRoot)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return session.MayorSessionName(townName), nil
|
||||||
|
}
|
||||||
|
|
||||||
var mayorCmd = &cobra.Command{
|
var mayorCmd = &cobra.Command{
|
||||||
Use: "mayor",
|
Use: "mayor",
|
||||||
@@ -88,8 +99,13 @@ func init() {
|
|||||||
func runMayorStart(cmd *cobra.Command, args []string) error {
|
func runMayorStart(cmd *cobra.Command, args []string) error {
|
||||||
t := tmux.NewTmux()
|
t := tmux.NewTmux()
|
||||||
|
|
||||||
|
sessionName, err := getMayorSessionName()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// Check if session already exists
|
// Check if session already exists
|
||||||
running, err := t.HasSession(MayorSessionName)
|
running, err := t.HasSession(sessionName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("checking session: %w", err)
|
return fmt.Errorf("checking session: %w", err)
|
||||||
}
|
}
|
||||||
@@ -97,7 +113,7 @@ func runMayorStart(cmd *cobra.Command, args []string) error {
|
|||||||
return fmt.Errorf("Mayor session already running. Attach with: gt mayor attach")
|
return fmt.Errorf("Mayor session already running. Attach with: gt mayor attach")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := startMayorSession(t); err != nil {
|
if err := startMayorSession(t, sessionName); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,7 +125,7 @@ func runMayorStart(cmd *cobra.Command, args []string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// startMayorSession creates and initializes the Mayor tmux session.
|
// startMayorSession creates and initializes the Mayor tmux session.
|
||||||
func startMayorSession(t *tmux.Tmux) error {
|
func startMayorSession(t *tmux.Tmux, sessionName string) error {
|
||||||
// Find workspace root
|
// Find workspace root
|
||||||
townRoot, err := workspace.FindFromCwdOrError()
|
townRoot, err := workspace.FindFromCwdOrError()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -118,36 +134,36 @@ func startMayorSession(t *tmux.Tmux) error {
|
|||||||
|
|
||||||
// Create session in workspace root
|
// Create session in workspace root
|
||||||
fmt.Println("Starting Mayor session...")
|
fmt.Println("Starting Mayor session...")
|
||||||
if err := t.NewSession(MayorSessionName, townRoot); err != nil {
|
if err := t.NewSession(sessionName, townRoot); err != nil {
|
||||||
return fmt.Errorf("creating session: %w", err)
|
return fmt.Errorf("creating session: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set environment (non-fatal: session works without these)
|
// Set environment (non-fatal: session works without these)
|
||||||
_ = t.SetEnvironment(MayorSessionName, "GT_ROLE", "mayor")
|
_ = t.SetEnvironment(sessionName, "GT_ROLE", "mayor")
|
||||||
_ = t.SetEnvironment(MayorSessionName, "BD_ACTOR", "mayor")
|
_ = t.SetEnvironment(sessionName, "BD_ACTOR", "mayor")
|
||||||
|
|
||||||
// Apply Mayor theme (non-fatal: theming failure doesn't affect operation)
|
// Apply Mayor theme (non-fatal: theming failure doesn't affect operation)
|
||||||
// Note: ConfigureGasTownSession includes cycle bindings
|
// Note: ConfigureGasTownSession includes cycle bindings
|
||||||
theme := tmux.MayorTheme()
|
theme := tmux.MayorTheme()
|
||||||
_ = t.ConfigureGasTownSession(MayorSessionName, theme, "", "Mayor", "coordinator")
|
_ = t.ConfigureGasTownSession(sessionName, theme, "", "Mayor", "coordinator")
|
||||||
|
|
||||||
// Launch Claude - the startup hook handles 'gt prime' automatically
|
// Launch Claude - the startup hook handles 'gt prime' automatically
|
||||||
// Use SendKeysDelayed to allow shell initialization after NewSession
|
// Use SendKeysDelayed to allow shell initialization after NewSession
|
||||||
// Export GT_ROLE and BD_ACTOR in the command since tmux SetEnvironment only affects new panes
|
// Export GT_ROLE and BD_ACTOR in the command since tmux SetEnvironment only affects new panes
|
||||||
// Mayor uses default runtime config (empty rigPath) since it's not rig-specific
|
// Mayor uses default runtime config (empty rigPath) since it's not rig-specific
|
||||||
claudeCmd := config.BuildAgentStartupCommand("mayor", "mayor", "", "")
|
claudeCmd := config.BuildAgentStartupCommand("mayor", "mayor", "", "")
|
||||||
if err := t.SendKeysDelayed(MayorSessionName, claudeCmd, 200); err != nil {
|
if err := t.SendKeysDelayed(sessionName, claudeCmd, 200); err != nil {
|
||||||
return fmt.Errorf("sending command: %w", err)
|
return fmt.Errorf("sending command: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for Claude to start (non-fatal)
|
// Wait for Claude to start (non-fatal)
|
||||||
if err := t.WaitForCommand(MayorSessionName, constants.SupportedShells, constants.ClaudeStartTimeout); err != nil {
|
if err := t.WaitForCommand(sessionName, constants.SupportedShells, constants.ClaudeStartTimeout); err != nil {
|
||||||
// Non-fatal
|
// Non-fatal
|
||||||
}
|
}
|
||||||
time.Sleep(constants.ShutdownNotifyDelay)
|
time.Sleep(constants.ShutdownNotifyDelay)
|
||||||
|
|
||||||
// Inject startup nudge for predecessor discovery via /resume
|
// Inject startup nudge for predecessor discovery via /resume
|
||||||
_ = session.StartupNudge(t, MayorSessionName, session.StartupNudgeConfig{
|
_ = session.StartupNudge(t, sessionName, session.StartupNudgeConfig{
|
||||||
Recipient: "mayor",
|
Recipient: "mayor",
|
||||||
Sender: "human",
|
Sender: "human",
|
||||||
Topic: "cold-start",
|
Topic: "cold-start",
|
||||||
@@ -157,7 +173,7 @@ func startMayorSession(t *tmux.Tmux) error {
|
|||||||
// Send the propulsion nudge to trigger autonomous coordination.
|
// Send the propulsion nudge to trigger autonomous coordination.
|
||||||
// Wait for beacon to be fully processed (needs to be separate prompt)
|
// Wait for beacon to be fully processed (needs to be separate prompt)
|
||||||
time.Sleep(2 * time.Second)
|
time.Sleep(2 * time.Second)
|
||||||
_ = t.NudgeSession(MayorSessionName, session.PropulsionNudgeForRole("mayor", townRoot)) // Non-fatal
|
_ = t.NudgeSession(sessionName, session.PropulsionNudgeForRole("mayor", townRoot)) // Non-fatal
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -165,8 +181,13 @@ func startMayorSession(t *tmux.Tmux) error {
|
|||||||
func runMayorStop(cmd *cobra.Command, args []string) error {
|
func runMayorStop(cmd *cobra.Command, args []string) error {
|
||||||
t := tmux.NewTmux()
|
t := tmux.NewTmux()
|
||||||
|
|
||||||
|
sessionName, err := getMayorSessionName()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// Check if session exists
|
// Check if session exists
|
||||||
running, err := t.HasSession(MayorSessionName)
|
running, err := t.HasSession(sessionName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("checking session: %w", err)
|
return fmt.Errorf("checking session: %w", err)
|
||||||
}
|
}
|
||||||
@@ -177,11 +198,11 @@ func runMayorStop(cmd *cobra.Command, args []string) error {
|
|||||||
fmt.Println("Stopping Mayor session...")
|
fmt.Println("Stopping Mayor session...")
|
||||||
|
|
||||||
// Try graceful shutdown first (best-effort interrupt)
|
// Try graceful shutdown first (best-effort interrupt)
|
||||||
_ = t.SendKeysRaw(MayorSessionName, "C-c")
|
_ = t.SendKeysRaw(sessionName, "C-c")
|
||||||
time.Sleep(100 * time.Millisecond)
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
// Kill the session
|
// Kill the session
|
||||||
if err := t.KillSession(MayorSessionName); err != nil {
|
if err := t.KillSession(sessionName); err != nil {
|
||||||
return fmt.Errorf("killing session: %w", err)
|
return fmt.Errorf("killing session: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,34 +213,44 @@ func runMayorStop(cmd *cobra.Command, args []string) error {
|
|||||||
func runMayorAttach(cmd *cobra.Command, args []string) error {
|
func runMayorAttach(cmd *cobra.Command, args []string) error {
|
||||||
t := tmux.NewTmux()
|
t := tmux.NewTmux()
|
||||||
|
|
||||||
|
sessionName, err := getMayorSessionName()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// Check if session exists
|
// Check if session exists
|
||||||
running, err := t.HasSession(MayorSessionName)
|
running, err := t.HasSession(sessionName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("checking session: %w", err)
|
return fmt.Errorf("checking session: %w", err)
|
||||||
}
|
}
|
||||||
if !running {
|
if !running {
|
||||||
// Auto-start if not running
|
// Auto-start if not running
|
||||||
fmt.Println("Mayor session not running, starting...")
|
fmt.Println("Mayor session not running, starting...")
|
||||||
if err := startMayorSession(t); err != nil {
|
if err := startMayorSession(t, sessionName); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use shared attach helper (smart: links if inside tmux, attaches if outside)
|
// Use shared attach helper (smart: links if inside tmux, attaches if outside)
|
||||||
return attachToTmuxSession(MayorSessionName)
|
return attachToTmuxSession(sessionName)
|
||||||
}
|
}
|
||||||
|
|
||||||
func runMayorStatus(cmd *cobra.Command, args []string) error {
|
func runMayorStatus(cmd *cobra.Command, args []string) error {
|
||||||
t := tmux.NewTmux()
|
t := tmux.NewTmux()
|
||||||
|
|
||||||
running, err := t.HasSession(MayorSessionName)
|
sessionName, err := getMayorSessionName()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
running, err := t.HasSession(sessionName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("checking session: %w", err)
|
return fmt.Errorf("checking session: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if running {
|
if running {
|
||||||
// Get session info for more details
|
// Get session info for more details
|
||||||
info, err := t.GetSessionInfo(MayorSessionName)
|
info, err := t.GetSessionInfo(sessionName)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
status := "detached"
|
status := "detached"
|
||||||
if info.Attached {
|
if info.Attached {
|
||||||
@@ -249,7 +280,12 @@ func runMayorStatus(cmd *cobra.Command, args []string) error {
|
|||||||
func runMayorRestart(cmd *cobra.Command, args []string) error {
|
func runMayorRestart(cmd *cobra.Command, args []string) error {
|
||||||
t := tmux.NewTmux()
|
t := tmux.NewTmux()
|
||||||
|
|
||||||
running, err := t.HasSession(MayorSessionName)
|
sessionName, err := getMayorSessionName()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
running, err := t.HasSession(sessionName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("checking session: %w", err)
|
return fmt.Errorf("checking session: %w", err)
|
||||||
}
|
}
|
||||||
@@ -257,9 +293,9 @@ func runMayorRestart(cmd *cobra.Command, args []string) error {
|
|||||||
if running {
|
if running {
|
||||||
// Stop the current session (best-effort interrupt before kill)
|
// Stop the current session (best-effort interrupt before kill)
|
||||||
fmt.Println("Stopping Mayor session...")
|
fmt.Println("Stopping Mayor session...")
|
||||||
_ = t.SendKeysRaw(MayorSessionName, "C-c")
|
_ = t.SendKeysRaw(sessionName, "C-c")
|
||||||
time.Sleep(100 * time.Millisecond)
|
time.Sleep(100 * time.Millisecond)
|
||||||
if err := t.KillSession(MayorSessionName); err != nil {
|
if err := t.KillSession(sessionName); err != nil {
|
||||||
return fmt.Errorf("killing session: %w", err)
|
return fmt.Errorf("killing session: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+29
-14
@@ -120,11 +120,17 @@ func runNudge(cmd *cobra.Command, args []string) error {
|
|||||||
|
|
||||||
t := tmux.NewTmux()
|
t := tmux.NewTmux()
|
||||||
|
|
||||||
|
// Get session names for this town
|
||||||
|
townName := ""
|
||||||
|
if townRoot != "" {
|
||||||
|
townName, _ = workspace.GetTownName(townRoot)
|
||||||
|
}
|
||||||
|
|
||||||
// Expand role shortcuts to session names
|
// Expand role shortcuts to session names
|
||||||
// These shortcuts let users type "mayor" instead of "gt-mayor"
|
// These shortcuts let users type "mayor" instead of "gt-{town}-mayor"
|
||||||
switch target {
|
switch target {
|
||||||
case "mayor":
|
case "mayor":
|
||||||
target = session.MayorSessionName()
|
target = session.MayorSessionName(townName)
|
||||||
case "witness", "refinery":
|
case "witness", "refinery":
|
||||||
// These need the current rig
|
// These need the current rig
|
||||||
roleInfo, err := GetRole()
|
roleInfo, err := GetRole()
|
||||||
@@ -143,8 +149,9 @@ func runNudge(cmd *cobra.Command, args []string) error {
|
|||||||
|
|
||||||
// Special case: "deacon" target maps to the Deacon session
|
// Special case: "deacon" target maps to the Deacon session
|
||||||
if target == "deacon" {
|
if target == "deacon" {
|
||||||
|
deaconSession := session.DeaconSessionName(townName)
|
||||||
// Check if Deacon session exists
|
// Check if Deacon session exists
|
||||||
exists, err := t.HasSession(DeaconSessionName)
|
exists, err := t.HasSession(deaconSession)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("checking deacon session: %w", err)
|
return fmt.Errorf("checking deacon session: %w", err)
|
||||||
}
|
}
|
||||||
@@ -154,7 +161,7 @@ func runNudge(cmd *cobra.Command, args []string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := t.NudgeSession(DeaconSessionName, message); err != nil {
|
if err := t.NudgeSession(deaconSession, message); err != nil {
|
||||||
return fmt.Errorf("nudging deacon: %w", err)
|
return fmt.Errorf("nudging deacon: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,6 +286,9 @@ func runNudgeChannel(channelName, message string) error {
|
|||||||
// Prefix message with sender
|
// Prefix message with sender
|
||||||
prefixedMessage := fmt.Sprintf("[from %s] %s", sender, message)
|
prefixedMessage := fmt.Sprintf("[from %s] %s", sender, message)
|
||||||
|
|
||||||
|
// Get town name for session names
|
||||||
|
townName, _ := workspace.GetTownName(townRoot)
|
||||||
|
|
||||||
// Get all running sessions for pattern matching
|
// Get all running sessions for pattern matching
|
||||||
agents, err := getAgentSessions(true)
|
agents, err := getAgentSessions(true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -290,7 +300,7 @@ func runNudgeChannel(channelName, message string) error {
|
|||||||
seenTargets := make(map[string]bool)
|
seenTargets := make(map[string]bool)
|
||||||
|
|
||||||
for _, pattern := range patterns {
|
for _, pattern := range patterns {
|
||||||
resolved := resolveNudgePattern(pattern, agents)
|
resolved := resolveNudgePattern(pattern, agents, townName)
|
||||||
for _, sessionName := range resolved {
|
for _, sessionName := range resolved {
|
||||||
if !seenTargets[sessionName] {
|
if !seenTargets[sessionName] {
|
||||||
seenTargets[sessionName] = true
|
seenTargets[sessionName] = true
|
||||||
@@ -350,16 +360,17 @@ func runNudgeChannel(channelName, message string) error {
|
|||||||
// - Literal: "gastown/witness" → gt-gastown-witness
|
// - Literal: "gastown/witness" → gt-gastown-witness
|
||||||
// - Wildcard: "gastown/polecats/*" → all polecat sessions in gastown
|
// - Wildcard: "gastown/polecats/*" → all polecat sessions in gastown
|
||||||
// - Role: "*/witness" → all witness sessions
|
// - Role: "*/witness" → all witness sessions
|
||||||
// - Special: "mayor", "deacon" → gt-mayor, gt-deacon
|
// - Special: "mayor", "deacon" → gt-{town}-mayor, gt-{town}-deacon
|
||||||
func resolveNudgePattern(pattern string, agents []*AgentSession) []string {
|
// townName is used to generate the correct session names for mayor/deacon.
|
||||||
|
func resolveNudgePattern(pattern string, agents []*AgentSession, townName string) []string {
|
||||||
var results []string
|
var results []string
|
||||||
|
|
||||||
// Handle special cases
|
// Handle special cases
|
||||||
switch pattern {
|
switch pattern {
|
||||||
case "mayor":
|
case "mayor":
|
||||||
return []string{session.MayorSessionName()}
|
return []string{session.MayorSessionName(townName)}
|
||||||
case "deacon":
|
case "deacon":
|
||||||
return []string{DeaconSessionName}
|
return []string{session.DeaconSessionName(townName)}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse pattern
|
// Parse pattern
|
||||||
@@ -427,8 +438,11 @@ func shouldNudgeTarget(townRoot, targetAddress string, force bool) (bool, string
|
|||||||
return true, "", nil
|
return true, "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get town name for session name generation
|
||||||
|
townName, _ := workspace.GetTownName(townRoot)
|
||||||
|
|
||||||
// Try to determine agent bead ID from address
|
// Try to determine agent bead ID from address
|
||||||
agentBeadID := addressToAgentBeadID(targetAddress)
|
agentBeadID := addressToAgentBeadID(targetAddress, townName)
|
||||||
if agentBeadID == "" {
|
if agentBeadID == "" {
|
||||||
// Can't determine agent bead, allow the nudge
|
// Can't determine agent bead, allow the nudge
|
||||||
return true, "", nil
|
return true, "", nil
|
||||||
@@ -447,18 +461,19 @@ func shouldNudgeTarget(townRoot, targetAddress string, force bool) (bool, string
|
|||||||
|
|
||||||
// addressToAgentBeadID converts a target address to an agent bead ID.
|
// addressToAgentBeadID converts a target address to an agent bead ID.
|
||||||
// Examples:
|
// Examples:
|
||||||
// - "mayor" -> "gt-mayor" (or similar)
|
// - "mayor" -> "gt-{town}-mayor"
|
||||||
|
// - "deacon" -> "gt-{town}-deacon"
|
||||||
// - "gastown/witness" -> "gt-gastown-witness"
|
// - "gastown/witness" -> "gt-gastown-witness"
|
||||||
// - "gastown/alpha" -> "gt-gastown-polecat-alpha"
|
// - "gastown/alpha" -> "gt-gastown-polecat-alpha"
|
||||||
//
|
//
|
||||||
// Returns empty string if the address cannot be converted.
|
// Returns empty string if the address cannot be converted.
|
||||||
func addressToAgentBeadID(address string) string {
|
func addressToAgentBeadID(address, townName string) string {
|
||||||
// Handle special cases
|
// Handle special cases
|
||||||
switch address {
|
switch address {
|
||||||
case "mayor":
|
case "mayor":
|
||||||
return "gt-mayor"
|
return session.MayorSessionName(townName)
|
||||||
case "deacon":
|
case "deacon":
|
||||||
return "gt-deacon"
|
return session.DeaconSessionName(townName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse rig/role format
|
// Parse rig/role format
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import (
|
|||||||
func TestResolveNudgePattern(t *testing.T) {
|
func TestResolveNudgePattern(t *testing.T) {
|
||||||
// Create test agent sessions
|
// Create test agent sessions
|
||||||
agents := []*AgentSession{
|
agents := []*AgentSession{
|
||||||
{Name: "gt-mayor", Type: AgentMayor},
|
{Name: "gt-ai-mayor", Type: AgentMayor, Town: "ai"},
|
||||||
{Name: "gt-deacon", Type: AgentDeacon},
|
{Name: "gt-ai-deacon", Type: AgentDeacon, Town: "ai"},
|
||||||
{Name: "gt-gastown-witness", Type: AgentWitness, Rig: "gastown"},
|
{Name: "gt-gastown-witness", Type: AgentWitness, Rig: "gastown"},
|
||||||
{Name: "gt-gastown-refinery", Type: AgentRefinery, Rig: "gastown"},
|
{Name: "gt-gastown-refinery", Type: AgentRefinery, Rig: "gastown"},
|
||||||
{Name: "gt-gastown-crew-max", Type: AgentCrew, Rig: "gastown", AgentName: "max"},
|
{Name: "gt-gastown-crew-max", Type: AgentCrew, Rig: "gastown", AgentName: "max"},
|
||||||
@@ -27,12 +27,12 @@ func TestResolveNudgePattern(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "mayor special case",
|
name: "mayor special case",
|
||||||
pattern: "mayor",
|
pattern: "mayor",
|
||||||
expected: []string{"gt-mayor"},
|
expected: []string{"gt-ai-mayor"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "deacon special case",
|
name: "deacon special case",
|
||||||
pattern: "deacon",
|
pattern: "deacon",
|
||||||
expected: []string{"gt-deacon"},
|
expected: []string{"gt-ai-deacon"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "specific witness",
|
name: "specific witness",
|
||||||
@@ -86,9 +86,10 @@ func TestResolveNudgePattern(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
townName := "ai"
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
got := resolveNudgePattern(tt.pattern, agents)
|
got := resolveNudgePattern(tt.pattern, agents, townName)
|
||||||
|
|
||||||
if len(got) != len(tt.expected) {
|
if len(got) != len(tt.expected) {
|
||||||
t.Errorf("resolveNudgePattern(%q) returned %d results, want %d: got %v, want %v",
|
t.Errorf("resolveNudgePattern(%q) returned %d results, want %d: got %v, want %v",
|
||||||
|
|||||||
@@ -88,8 +88,16 @@ func parsePolecatSessionName(sessionName string) (rigName, polecatName string, o
|
|||||||
return "", "", false
|
return "", "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exclude town-level sessions
|
// Exclude town-level sessions by exact match
|
||||||
if sessionName == "gt-mayor" || sessionName == "gt-deacon" {
|
mayorSession, _ := getMayorSessionName()
|
||||||
|
deaconSession, _ := getDeaconSessionName()
|
||||||
|
if sessionName == mayorSession || sessionName == deaconSession {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also exclude by suffix pattern (gt-{town}-mayor, gt-{town}-deacon)
|
||||||
|
// This handles cases where town config isn't available
|
||||||
|
if strings.HasSuffix(sessionName, "-mayor") || strings.HasSuffix(sessionName, "-deacon") {
|
||||||
return "", "", false
|
return "", "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -64,14 +64,14 @@ func TestParsePolecatSessionName(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "mayor session",
|
name: "mayor session",
|
||||||
sessionName: "gt-mayor",
|
sessionName: "gt-ai-mayor",
|
||||||
wantRig: "",
|
wantRig: "",
|
||||||
wantPolecat: "",
|
wantPolecat: "",
|
||||||
wantOk: false,
|
wantOk: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "deacon session",
|
name: "deacon session",
|
||||||
sessionName: "gt-deacon",
|
sessionName: "gt-ai-deacon",
|
||||||
wantRig: "",
|
wantRig: "",
|
||||||
wantPolecat: "",
|
wantPolecat: "",
|
||||||
wantOk: false,
|
wantOk: false,
|
||||||
|
|||||||
+12
-5
@@ -19,6 +19,7 @@ import (
|
|||||||
"github.com/steveyegge/gastown/internal/constants"
|
"github.com/steveyegge/gastown/internal/constants"
|
||||||
"github.com/steveyegge/gastown/internal/events"
|
"github.com/steveyegge/gastown/internal/events"
|
||||||
"github.com/steveyegge/gastown/internal/lock"
|
"github.com/steveyegge/gastown/internal/lock"
|
||||||
|
"github.com/steveyegge/gastown/internal/session"
|
||||||
"github.com/steveyegge/gastown/internal/style"
|
"github.com/steveyegge/gastown/internal/style"
|
||||||
"github.com/steveyegge/gastown/internal/templates"
|
"github.com/steveyegge/gastown/internal/templates"
|
||||||
"github.com/steveyegge/gastown/internal/workspace"
|
"github.com/steveyegge/gastown/internal/workspace"
|
||||||
@@ -304,12 +305,18 @@ func outputPrimeContext(ctx RoleContext) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build template data
|
// Build template data
|
||||||
|
// Get town name for session names
|
||||||
|
townName, _ := workspace.GetTownName(ctx.TownRoot)
|
||||||
|
|
||||||
data := templates.RoleData{
|
data := templates.RoleData{
|
||||||
Role: roleName,
|
Role: roleName,
|
||||||
RigName: ctx.Rig,
|
RigName: ctx.Rig,
|
||||||
TownRoot: ctx.TownRoot,
|
TownRoot: ctx.TownRoot,
|
||||||
WorkDir: ctx.WorkDir,
|
TownName: townName,
|
||||||
Polecat: ctx.Polecat,
|
WorkDir: ctx.WorkDir,
|
||||||
|
Polecat: ctx.Polecat,
|
||||||
|
MayorSession: session.MayorSessionName(townName),
|
||||||
|
DeaconSession: session.DeaconSessionName(townName),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render and output
|
// Render and output
|
||||||
|
|||||||
@@ -1117,7 +1117,9 @@ func DispatchToDog(dogName string, create bool) (*DogDispatchInfo, error) {
|
|||||||
agentID := fmt.Sprintf("deacon/dogs/%s", targetDog.Name)
|
agentID := fmt.Sprintf("deacon/dogs/%s", targetDog.Name)
|
||||||
|
|
||||||
// Try to find tmux session for the dog (dogs may run in tmux like polecats)
|
// Try to find tmux session for the dog (dogs may run in tmux like polecats)
|
||||||
sessionName := fmt.Sprintf("gt-deacon-%s", targetDog.Name)
|
// Dogs use the pattern gt-{town}-deacon-{name}
|
||||||
|
townName, _ := workspace.GetTownName(townRoot)
|
||||||
|
sessionName := fmt.Sprintf("gt-%s-deacon-%s", townName, targetDog.Name)
|
||||||
t := tmux.NewTmux()
|
t := tmux.NewTmux()
|
||||||
var pane string
|
var pane string
|
||||||
if has, _ := t.HasSession(sessionName); has {
|
if has, _ := t.HasSession(sessionName); has {
|
||||||
|
|||||||
+36
-17
@@ -178,25 +178,35 @@ func runStart(cmd *cobra.Command, args []string) error {
|
|||||||
|
|
||||||
// startCoreAgents starts Mayor and Deacon sessions.
|
// startCoreAgents starts Mayor and Deacon sessions.
|
||||||
func startCoreAgents(t *tmux.Tmux) error {
|
func startCoreAgents(t *tmux.Tmux) error {
|
||||||
|
// Get session names
|
||||||
|
mayorSession, err := getMayorSessionName()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("getting Mayor session name: %w", err)
|
||||||
|
}
|
||||||
|
deaconSession, err := getDeaconSessionName()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("getting Deacon session name: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Start Mayor first (so Deacon sees it as up)
|
// Start Mayor first (so Deacon sees it as up)
|
||||||
mayorRunning, _ := t.HasSession(MayorSessionName)
|
mayorRunning, _ := t.HasSession(mayorSession)
|
||||||
if mayorRunning {
|
if mayorRunning {
|
||||||
fmt.Printf(" %s Mayor already running\n", style.Dim.Render("○"))
|
fmt.Printf(" %s Mayor already running\n", style.Dim.Render("○"))
|
||||||
} else {
|
} else {
|
||||||
fmt.Printf(" %s Starting Mayor...\n", style.Bold.Render("→"))
|
fmt.Printf(" %s Starting Mayor...\n", style.Bold.Render("→"))
|
||||||
if err := startMayorSession(t); err != nil {
|
if err := startMayorSession(t, mayorSession); err != nil {
|
||||||
return fmt.Errorf("starting Mayor: %w", err)
|
return fmt.Errorf("starting Mayor: %w", err)
|
||||||
}
|
}
|
||||||
fmt.Printf(" %s Mayor started\n", style.Bold.Render("✓"))
|
fmt.Printf(" %s Mayor started\n", style.Bold.Render("✓"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start Deacon (health monitor)
|
// Start Deacon (health monitor)
|
||||||
deaconRunning, _ := t.HasSession(DeaconSessionName)
|
deaconRunning, _ := t.HasSession(deaconSession)
|
||||||
if deaconRunning {
|
if deaconRunning {
|
||||||
fmt.Printf(" %s Deacon already running\n", style.Dim.Render("○"))
|
fmt.Printf(" %s Deacon already running\n", style.Dim.Render("○"))
|
||||||
} else {
|
} else {
|
||||||
fmt.Printf(" %s Starting Deacon...\n", style.Bold.Render("→"))
|
fmt.Printf(" %s Starting Deacon...\n", style.Bold.Render("→"))
|
||||||
if err := startDeaconSession(t); err != nil {
|
if err := startDeaconSession(t, deaconSession); err != nil {
|
||||||
return fmt.Errorf("starting Deacon: %w", err)
|
return fmt.Errorf("starting Deacon: %w", err)
|
||||||
}
|
}
|
||||||
fmt.Printf(" %s Deacon started\n", style.Bold.Render("✓"))
|
fmt.Printf(" %s Deacon started\n", style.Bold.Render("✓"))
|
||||||
@@ -380,7 +390,10 @@ func runShutdown(cmd *cobra.Command, args []string) error {
|
|||||||
return fmt.Errorf("listing sessions: %w", err)
|
return fmt.Errorf("listing sessions: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
toStop, preserved := categorizeSessions(sessions)
|
// Get session names for categorization
|
||||||
|
mayorSession, _ := getMayorSessionName()
|
||||||
|
deaconSession, _ := getDeaconSessionName()
|
||||||
|
toStop, preserved := categorizeSessions(sessions, mayorSession, deaconSession)
|
||||||
|
|
||||||
if len(toStop) == 0 {
|
if len(toStop) == 0 {
|
||||||
fmt.Printf("%s Gas Town was not running\n", style.Dim.Render("○"))
|
fmt.Printf("%s Gas Town was not running\n", style.Dim.Render("○"))
|
||||||
@@ -420,7 +433,8 @@ func runShutdown(cmd *cobra.Command, args []string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// categorizeSessions splits sessions into those to stop and those to preserve.
|
// categorizeSessions splits sessions into those to stop and those to preserve.
|
||||||
func categorizeSessions(sessions []string) (toStop, preserved []string) {
|
// mayorSession and deaconSession are the dynamic session names for the current town.
|
||||||
|
func categorizeSessions(sessions []string, mayorSession, deaconSession string) (toStop, preserved []string) {
|
||||||
for _, sess := range sessions {
|
for _, sess := range sessions {
|
||||||
if !strings.HasPrefix(sess, "gt-") {
|
if !strings.HasPrefix(sess, "gt-") {
|
||||||
continue // Not a Gas Town session
|
continue // Not a Gas Town session
|
||||||
@@ -431,7 +445,7 @@ func categorizeSessions(sessions []string) (toStop, preserved []string) {
|
|||||||
|
|
||||||
// Check if it's a polecat session (pattern: gt-<rig>-<name> where name is not crew/witness/refinery)
|
// Check if it's a polecat session (pattern: gt-<rig>-<name> where name is not crew/witness/refinery)
|
||||||
isPolecat := false
|
isPolecat := false
|
||||||
if !isCrew && sess != MayorSessionName && sess != DeaconSessionName {
|
if !isCrew && sess != mayorSession && sess != deaconSession {
|
||||||
parts := strings.Split(sess, "-")
|
parts := strings.Split(sess, "-")
|
||||||
if len(parts) >= 3 {
|
if len(parts) >= 3 {
|
||||||
role := parts[2]
|
role := parts[2]
|
||||||
@@ -501,7 +515,9 @@ func runGracefulShutdown(t *tmux.Tmux, gtSessions []string, townRoot string) err
|
|||||||
|
|
||||||
// Phase 4: Kill sessions in correct order
|
// Phase 4: Kill sessions in correct order
|
||||||
fmt.Printf("\nPhase 4: Terminating sessions...\n")
|
fmt.Printf("\nPhase 4: Terminating sessions...\n")
|
||||||
stopped := killSessionsInOrder(t, gtSessions)
|
mayorSession, _ := getMayorSessionName()
|
||||||
|
deaconSession, _ := getDeaconSessionName()
|
||||||
|
stopped := killSessionsInOrder(t, gtSessions, mayorSession, deaconSession)
|
||||||
|
|
||||||
// Phase 5: Cleanup polecat worktrees and branches
|
// Phase 5: Cleanup polecat worktrees and branches
|
||||||
fmt.Printf("\nPhase 5: Cleaning up polecats...\n")
|
fmt.Printf("\nPhase 5: Cleaning up polecats...\n")
|
||||||
@@ -517,7 +533,9 @@ func runGracefulShutdown(t *tmux.Tmux, gtSessions []string, townRoot string) err
|
|||||||
func runImmediateShutdown(t *tmux.Tmux, gtSessions []string, townRoot string) error {
|
func runImmediateShutdown(t *tmux.Tmux, gtSessions []string, townRoot string) error {
|
||||||
fmt.Println("Shutting down Gas Town...")
|
fmt.Println("Shutting down Gas Town...")
|
||||||
|
|
||||||
stopped := killSessionsInOrder(t, gtSessions)
|
mayorSession, _ := getMayorSessionName()
|
||||||
|
deaconSession, _ := getDeaconSessionName()
|
||||||
|
stopped := killSessionsInOrder(t, gtSessions, mayorSession, deaconSession)
|
||||||
|
|
||||||
// Cleanup polecat worktrees and branches
|
// Cleanup polecat worktrees and branches
|
||||||
if townRoot != "" {
|
if townRoot != "" {
|
||||||
@@ -536,7 +554,8 @@ func runImmediateShutdown(t *tmux.Tmux, gtSessions []string, townRoot string) er
|
|||||||
// 1. Deacon first (so it doesn't restart others)
|
// 1. Deacon first (so it doesn't restart others)
|
||||||
// 2. Everything except Mayor
|
// 2. Everything except Mayor
|
||||||
// 3. Mayor last
|
// 3. Mayor last
|
||||||
func killSessionsInOrder(t *tmux.Tmux, sessions []string) int {
|
// mayorSession and deaconSession are the dynamic session names for the current town.
|
||||||
|
func killSessionsInOrder(t *tmux.Tmux, sessions []string, mayorSession, deaconSession string) int {
|
||||||
stopped := 0
|
stopped := 0
|
||||||
|
|
||||||
// Helper to check if session is in our list
|
// Helper to check if session is in our list
|
||||||
@@ -550,16 +569,16 @@ func killSessionsInOrder(t *tmux.Tmux, sessions []string) int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 1. Stop Deacon first
|
// 1. Stop Deacon first
|
||||||
if inList(DeaconSessionName) {
|
if inList(deaconSession) {
|
||||||
if err := t.KillSession(DeaconSessionName); err == nil {
|
if err := t.KillSession(deaconSession); err == nil {
|
||||||
fmt.Printf(" %s %s stopped\n", style.Bold.Render("✓"), DeaconSessionName)
|
fmt.Printf(" %s %s stopped\n", style.Bold.Render("✓"), deaconSession)
|
||||||
stopped++
|
stopped++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Stop others (except Mayor)
|
// 2. Stop others (except Mayor)
|
||||||
for _, sess := range sessions {
|
for _, sess := range sessions {
|
||||||
if sess == DeaconSessionName || sess == MayorSessionName {
|
if sess == deaconSession || sess == mayorSession {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err := t.KillSession(sess); err == nil {
|
if err := t.KillSession(sess); err == nil {
|
||||||
@@ -569,9 +588,9 @@ func killSessionsInOrder(t *tmux.Tmux, sessions []string) int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3. Stop Mayor last
|
// 3. Stop Mayor last
|
||||||
if inList(MayorSessionName) {
|
if inList(mayorSession) {
|
||||||
if err := t.KillSession(MayorSessionName); err == nil {
|
if err := t.KillSession(mayorSession); err == nil {
|
||||||
fmt.Printf(" %s %s stopped\n", style.Bold.Render("✓"), MayorSessionName)
|
fmt.Printf(" %s %s stopped\n", style.Bold.Render("✓"), mayorSession)
|
||||||
stopped++
|
stopped++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -645,6 +645,10 @@ func discoverRigHooks(r *rig.Rig, crews []string) []AgentHookInfo {
|
|||||||
// allAgentBeads is a preloaded map of agent beads for O(1) lookup.
|
// allAgentBeads is a preloaded map of agent beads for O(1) lookup.
|
||||||
// allHookBeads is a preloaded map of hook beads for O(1) lookup.
|
// allHookBeads is a preloaded map of hook beads for O(1) lookup.
|
||||||
func discoverGlobalAgents(allSessions map[string]bool, allAgentBeads map[string]*beads.Issue, allHookBeads map[string]*beads.Issue, mailRouter *mail.Router, skipMail bool) []AgentRuntime {
|
func discoverGlobalAgents(allSessions map[string]bool, allAgentBeads map[string]*beads.Issue, allHookBeads map[string]*beads.Issue, mailRouter *mail.Router, skipMail bool) []AgentRuntime {
|
||||||
|
// Get session names dynamically
|
||||||
|
mayorSession, _ := getMayorSessionName()
|
||||||
|
deaconSession, _ := getDeaconSessionName()
|
||||||
|
|
||||||
// Define agents to discover
|
// Define agents to discover
|
||||||
agentDefs := []struct {
|
agentDefs := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -653,8 +657,8 @@ func discoverGlobalAgents(allSessions map[string]bool, allAgentBeads map[string]
|
|||||||
role string
|
role string
|
||||||
beadID string
|
beadID string
|
||||||
}{
|
}{
|
||||||
{"mayor", "mayor/", MayorSessionName, "coordinator", "gt-mayor"},
|
{"mayor", "mayor/", mayorSession, "coordinator", mayorSession},
|
||||||
{"deacon", "deacon/", DeaconSessionName, "health-check", "gt-deacon"},
|
{"deacon", "deacon/", deaconSession, "health-check", deaconSession},
|
||||||
}
|
}
|
||||||
|
|
||||||
agents := make([]AgentRuntime, len(agentDefs))
|
agents := make([]AgentRuntime, len(agentDefs))
|
||||||
|
|||||||
@@ -52,13 +52,17 @@ func runStatusLine(cmd *cobra.Command, args []string) error {
|
|||||||
role = os.Getenv("GT_ROLE")
|
role = os.Getenv("GT_ROLE")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get session names for comparison
|
||||||
|
mayorSession, _ := getMayorSessionName()
|
||||||
|
deaconSession, _ := getDeaconSessionName()
|
||||||
|
|
||||||
// Determine identity and output based on role
|
// Determine identity and output based on role
|
||||||
if role == "mayor" || statusLineSession == "gt-mayor" {
|
if role == "mayor" || statusLineSession == mayorSession {
|
||||||
return runMayorStatusLine(t)
|
return runMayorStatusLine(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deacon status line
|
// Deacon status line
|
||||||
if role == "deacon" || statusLineSession == "gt-deacon" {
|
if role == "deacon" || statusLineSession == deaconSession {
|
||||||
return runDeaconStatusLine(t)
|
return runDeaconStatusLine(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,7 +164,8 @@ func runMayorStatusLine(t *tmux.Tmux) error {
|
|||||||
|
|
||||||
// Get town root from mayor pane's working directory
|
// Get town root from mayor pane's working directory
|
||||||
var townRoot string
|
var townRoot string
|
||||||
paneDir, err := t.GetPaneWorkDir("gt-mayor")
|
mayorSession, _ := getMayorSessionName()
|
||||||
|
paneDir, err := t.GetPaneWorkDir(mayorSession)
|
||||||
if err == nil && paneDir != "" {
|
if err == nil && paneDir != "" {
|
||||||
townRoot, _ = workspace.Find(paneDir)
|
townRoot, _ = workspace.Find(paneDir)
|
||||||
}
|
}
|
||||||
@@ -236,7 +241,8 @@ func runDeaconStatusLine(t *tmux.Tmux) error {
|
|||||||
|
|
||||||
// Get town root from deacon pane's working directory
|
// Get town root from deacon pane's working directory
|
||||||
var townRoot string
|
var townRoot string
|
||||||
paneDir, err := t.GetPaneWorkDir("gt-deacon")
|
deaconSession, _ := getDeaconSessionName()
|
||||||
|
paneDir, err := t.GetPaneWorkDir(deaconSession)
|
||||||
if err == nil && paneDir != "" {
|
if err == nil && paneDir != "" {
|
||||||
townRoot, _ = workspace.Find(paneDir)
|
townRoot, _ = workspace.Find(paneDir)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,8 +31,8 @@ func TestCategorizeSessionRig(t *testing.T) {
|
|||||||
{"gt-a-b", "a"}, // minimum valid
|
{"gt-a-b", "a"}, // minimum valid
|
||||||
|
|
||||||
// Town-level agents (no rig)
|
// Town-level agents (no rig)
|
||||||
{"gt-mayor", ""},
|
{"gt-ai-mayor", ""},
|
||||||
{"gt-deacon", ""},
|
{"gt-ai-deacon", ""},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
@@ -68,8 +68,8 @@ func TestCategorizeSessionType(t *testing.T) {
|
|||||||
{"gt-myrig-crew-user", AgentCrew},
|
{"gt-myrig-crew-user", AgentCrew},
|
||||||
|
|
||||||
// Town-level agents
|
// Town-level agents
|
||||||
{"gt-mayor", AgentMayor},
|
{"gt-ai-mayor", AgentMayor},
|
||||||
{"gt-deacon", AgentDeacon},
|
{"gt-ai-deacon", AgentDeacon},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
|
|||||||
+25
-14
@@ -8,6 +8,7 @@ import (
|
|||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/steveyegge/gastown/internal/config"
|
"github.com/steveyegge/gastown/internal/config"
|
||||||
|
"github.com/steveyegge/gastown/internal/session"
|
||||||
"github.com/steveyegge/gastown/internal/tmux"
|
"github.com/steveyegge/gastown/internal/tmux"
|
||||||
"github.com/steveyegge/gastown/internal/workspace"
|
"github.com/steveyegge/gastown/internal/workspace"
|
||||||
)
|
)
|
||||||
@@ -116,10 +117,20 @@ func runThemeApply(cmd *cobra.Command, args []string) error {
|
|||||||
// Determine current rig
|
// Determine current rig
|
||||||
rigName := detectCurrentRig()
|
rigName := detectCurrentRig()
|
||||||
|
|
||||||
|
// Get town name for session name comparison
|
||||||
|
var mayorSession, deaconSession string
|
||||||
|
townRoot, err := workspace.FindFromCwd()
|
||||||
|
if err == nil && townRoot != "" {
|
||||||
|
if townName, err := workspace.GetTownName(townRoot); err == nil {
|
||||||
|
mayorSession = session.MayorSessionName(townName)
|
||||||
|
deaconSession = session.DeaconSessionName(townName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Apply to matching sessions
|
// Apply to matching sessions
|
||||||
applied := 0
|
applied := 0
|
||||||
for _, session := range sessions {
|
for _, sess := range sessions {
|
||||||
if !strings.HasPrefix(session, "gt-") {
|
if !strings.HasPrefix(sess, "gt-") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,23 +138,23 @@ func runThemeApply(cmd *cobra.Command, args []string) error {
|
|||||||
var theme tmux.Theme
|
var theme tmux.Theme
|
||||||
var rig, worker, role string
|
var rig, worker, role string
|
||||||
|
|
||||||
if session == "gt-mayor" {
|
if sess == mayorSession {
|
||||||
theme = tmux.MayorTheme()
|
theme = tmux.MayorTheme()
|
||||||
worker = "Mayor"
|
worker = "Mayor"
|
||||||
role = "coordinator"
|
role = "coordinator"
|
||||||
} else if session == "gt-deacon" {
|
} else if sess == deaconSession {
|
||||||
theme = tmux.DeaconTheme()
|
theme = tmux.DeaconTheme()
|
||||||
worker = "Deacon"
|
worker = "Deacon"
|
||||||
role = "health-check"
|
role = "health-check"
|
||||||
} else if strings.HasSuffix(session, "-witness") && strings.HasPrefix(session, "gt-") {
|
} else if strings.HasSuffix(sess, "-witness") && strings.HasPrefix(sess, "gt-") {
|
||||||
// Witness sessions: gt-<rig>-witness
|
// Witness sessions: gt-<rig>-witness
|
||||||
rig = strings.TrimPrefix(strings.TrimSuffix(session, "-witness"), "gt-")
|
rig = strings.TrimPrefix(strings.TrimSuffix(sess, "-witness"), "gt-")
|
||||||
theme = getThemeForRole(rig, "witness")
|
theme = getThemeForRole(rig, "witness")
|
||||||
worker = "witness"
|
worker = "witness"
|
||||||
role = "witness"
|
role = "witness"
|
||||||
} else {
|
} else {
|
||||||
// Parse session name: gt-<rig>-<worker> or gt-<rig>-crew-<name>
|
// Parse session name: gt-<rig>-<worker> or gt-<rig>-crew-<name>
|
||||||
parts := strings.SplitN(session, "-", 3)
|
parts := strings.SplitN(sess, "-", 3)
|
||||||
if len(parts) < 3 {
|
if len(parts) < 3 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -171,20 +182,20 @@ func runThemeApply(cmd *cobra.Command, args []string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Apply theme and status format
|
// Apply theme and status format
|
||||||
if err := t.ApplyTheme(session, theme); err != nil {
|
if err := t.ApplyTheme(sess, theme); err != nil {
|
||||||
fmt.Printf(" %s: failed (%v)\n", session, err)
|
fmt.Printf(" %s: failed (%v)\n", sess, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err := t.SetStatusFormat(session, rig, worker, role); err != nil {
|
if err := t.SetStatusFormat(sess, rig, worker, role); err != nil {
|
||||||
fmt.Printf(" %s: failed to set format (%v)\n", session, err)
|
fmt.Printf(" %s: failed to set format (%v)\n", sess, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err := t.SetDynamicStatus(session); err != nil {
|
if err := t.SetDynamicStatus(sess); err != nil {
|
||||||
fmt.Printf(" %s: failed to set dynamic status (%v)\n", session, err)
|
fmt.Printf(" %s: failed to set dynamic status (%v)\n", sess, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf(" %s: applied %s theme\n", session, theme.Name)
|
fmt.Printf(" %s: applied %s theme\n", sess, theme.Name)
|
||||||
applied++
|
applied++
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+34
-12
@@ -6,6 +6,7 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/steveyegge/gastown/internal/workspace"
|
||||||
)
|
)
|
||||||
|
|
||||||
// townCycleSession is the --session flag for town next/prev commands.
|
// townCycleSession is the --session flag for town next/prev commands.
|
||||||
@@ -13,9 +14,32 @@ import (
|
|||||||
// correct, so we pass the session name explicitly via #{session_name} expansion.
|
// correct, so we pass the session name explicitly via #{session_name} expansion.
|
||||||
var townCycleSession string
|
var townCycleSession string
|
||||||
|
|
||||||
// Town-level sessions that participate in cycling (mayor, deacon).
|
// getTownLevelSessions returns the town-level session names for the current workspace.
|
||||||
// These are the session names without the "gt-" prefix.
|
// Returns empty slice if workspace cannot be determined.
|
||||||
var townLevelSessions = []string{"gt-mayor", "gt-deacon"}
|
func getTownLevelSessions() []string {
|
||||||
|
mayorSession, errMayor := getMayorSessionName()
|
||||||
|
deaconSession, errDeacon := getDeaconSessionName()
|
||||||
|
if errMayor != nil || errDeacon != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return []string{mayorSession, deaconSession}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isTownLevelSession checks if the given session name is a town-level session.
|
||||||
|
func isTownLevelSession(sessionName string) bool {
|
||||||
|
townRoot, err := workspace.FindFromCwd()
|
||||||
|
if err != nil || townRoot == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
townName, err := workspace.GetTownName(townRoot)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
mayorSession, _ := getMayorSessionName()
|
||||||
|
deaconSession, _ := getDeaconSessionName()
|
||||||
|
_ = townName // used for session name generation
|
||||||
|
return sessionName == mayorSession || sessionName == deaconSession
|
||||||
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
rootCmd.AddCommand(townCmd)
|
rootCmd.AddCommand(townCmd)
|
||||||
@@ -78,15 +102,7 @@ func cycleTownSession(direction int, sessionOverride string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if current session is a town-level session
|
// Check if current session is a town-level session
|
||||||
isTownSession := false
|
if !isTownLevelSession(currentSession) {
|
||||||
for _, s := range townLevelSessions {
|
|
||||||
if s == currentSession {
|
|
||||||
isTownSession = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !isTownSession {
|
|
||||||
// Not a town session - no cycling, just stay put
|
// Not a town session - no cycling, just stay put
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -145,6 +161,12 @@ func findRunningTownSessions() ([]string, error) {
|
|||||||
return nil, fmt.Errorf("listing tmux sessions: %w", err)
|
return nil, fmt.Errorf("listing tmux sessions: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get town-level session names
|
||||||
|
townLevelSessions := getTownLevelSessions()
|
||||||
|
if townLevelSessions == nil {
|
||||||
|
return nil, fmt.Errorf("cannot determine town-level sessions")
|
||||||
|
}
|
||||||
|
|
||||||
var running []string
|
var running []string
|
||||||
for _, line := range splitLines(string(out)) {
|
for _, line := range splitLines(string(out)) {
|
||||||
if line == "" {
|
if line == "" {
|
||||||
|
|||||||
+8
-4
@@ -79,20 +79,24 @@ func runUp(cmd *cobra.Command, args []string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get session names
|
||||||
|
deaconSession, _ := getDeaconSessionName()
|
||||||
|
mayorSession, _ := getMayorSessionName()
|
||||||
|
|
||||||
// 2. Deacon (Claude agent)
|
// 2. Deacon (Claude agent)
|
||||||
if err := ensureSession(t, DeaconSessionName, townRoot, "deacon"); err != nil {
|
if err := ensureSession(t, deaconSession, townRoot, "deacon"); err != nil {
|
||||||
printStatus("Deacon", false, err.Error())
|
printStatus("Deacon", false, err.Error())
|
||||||
allOK = false
|
allOK = false
|
||||||
} else {
|
} else {
|
||||||
printStatus("Deacon", true, "gt-deacon")
|
printStatus("Deacon", true, deaconSession)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Mayor (Claude agent)
|
// 3. Mayor (Claude agent)
|
||||||
if err := ensureSession(t, MayorSessionName, townRoot, "mayor"); err != nil {
|
if err := ensureSession(t, mayorSession, townRoot, "mayor"); err != nil {
|
||||||
printStatus("Mayor", false, err.Error())
|
printStatus("Mayor", false, err.Error())
|
||||||
allOK = false
|
allOK = false
|
||||||
} else {
|
} else {
|
||||||
printStatus("Mayor", true, "gt-mayor")
|
printStatus("Mayor", true, mayorSession)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Witnesses (one per rig)
|
// 4. Witnesses (one per rig)
|
||||||
|
|||||||
@@ -90,13 +90,9 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Tmux session names.
|
// Tmux session names.
|
||||||
|
// Note: Mayor and Deacon session names are now dynamic (include town name).
|
||||||
|
// Use session.MayorSessionName(townName) and session.DeaconSessionName(townName).
|
||||||
const (
|
const (
|
||||||
// SessionMayor is the tmux session name for the mayor.
|
|
||||||
SessionMayor = "gt-mayor"
|
|
||||||
|
|
||||||
// SessionDeacon is the tmux session name for the deacon.
|
|
||||||
SessionDeacon = "gt-deacon"
|
|
||||||
|
|
||||||
// SessionPrefix is the prefix for all Gas Town tmux sessions.
|
// SessionPrefix is the prefix for all Gas Town tmux sessions.
|
||||||
SessionPrefix = "gt-"
|
SessionPrefix = "gt-"
|
||||||
)
|
)
|
||||||
|
|||||||
+28
-14
@@ -20,7 +20,9 @@ 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/session"
|
||||||
"github.com/steveyegge/gastown/internal/tmux"
|
"github.com/steveyegge/gastown/internal/tmux"
|
||||||
|
"github.com/steveyegge/gastown/internal/workspace"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Daemon is the town-level background service.
|
// Daemon is the town-level background service.
|
||||||
@@ -188,12 +190,20 @@ func (d *Daemon) heartbeat(state *State) {
|
|||||||
d.logger.Printf("Heartbeat complete (#%d)", state.HeartbeatCount)
|
d.logger.Printf("Heartbeat complete (#%d)", state.HeartbeatCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeaconSessionName is the tmux session name for the Deacon.
|
|
||||||
const DeaconSessionName = "gt-deacon"
|
|
||||||
|
|
||||||
// DeaconRole is the role name for the Deacon's handoff bead.
|
// DeaconRole is the role name for the Deacon's handoff bead.
|
||||||
const DeaconRole = "deacon"
|
const DeaconRole = "deacon"
|
||||||
|
|
||||||
|
// getDeaconSessionName returns the Deacon session name for the daemon's town.
|
||||||
|
func (d *Daemon) getDeaconSessionName() string {
|
||||||
|
townName, err := workspace.GetTownName(d.config.TownRoot)
|
||||||
|
if err != nil {
|
||||||
|
// Fallback to legacy name if town config can't be loaded
|
||||||
|
d.logger.Printf("Warning: failed to get town name: %v, using fallback", err)
|
||||||
|
return "gt-deacon"
|
||||||
|
}
|
||||||
|
return session.DeaconSessionName(townName)
|
||||||
|
}
|
||||||
|
|
||||||
// ensureBootRunning spawns Boot to triage the Deacon.
|
// ensureBootRunning spawns Boot to triage the Deacon.
|
||||||
// Boot is a fresh-each-tick watchdog that decides whether to start/wake/nudge
|
// Boot is a fresh-each-tick watchdog that decides whether to start/wake/nudge
|
||||||
// the Deacon, centralizing the "when to wake" decision in an agent.
|
// the Deacon, centralizing the "when to wake" decision in an agent.
|
||||||
@@ -238,7 +248,7 @@ func (d *Daemon) runDegradedBootTriage(b *boot.Boot) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Simple check: is Deacon session alive?
|
// Simple check: is Deacon session alive?
|
||||||
hasDeacon, err := d.tmux.HasSession(DeaconSessionName)
|
hasDeacon, err := d.tmux.HasSession(d.getDeaconSessionName())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
d.logger.Printf("Error checking Deacon session: %v", err)
|
d.logger.Printf("Error checking Deacon session: %v", err)
|
||||||
status.LastAction = "error"
|
status.LastAction = "error"
|
||||||
@@ -265,7 +275,7 @@ func (d *Daemon) runDegradedBootTriage(b *boot.Boot) {
|
|||||||
// The Deacon is the system's heartbeat - it must always be running.
|
// The Deacon is the system's heartbeat - it must always be running.
|
||||||
func (d *Daemon) ensureDeaconRunning() {
|
func (d *Daemon) ensureDeaconRunning() {
|
||||||
// Check agent bead state (ZFC: trust what agent reports)
|
// Check agent bead state (ZFC: trust what agent reports)
|
||||||
beadState, beadErr := d.getAgentBeadState("gt-deacon")
|
beadState, beadErr := d.getAgentBeadState(d.getDeaconSessionName())
|
||||||
if beadErr == nil {
|
if beadErr == nil {
|
||||||
if beadState == "running" || beadState == "working" {
|
if beadState == "running" || beadState == "working" {
|
||||||
// Agent reports it's running - trust it
|
// Agent reports it's running - trust it
|
||||||
@@ -277,9 +287,10 @@ func (d *Daemon) ensureDeaconRunning() {
|
|||||||
// Agent bead check failed or state is not running.
|
// Agent bead check failed or state is not running.
|
||||||
// FALLBACK: Check if tmux session is actually healthy before attempting restart.
|
// FALLBACK: Check if tmux session is actually healthy before attempting restart.
|
||||||
// This prevents killing healthy sessions when bead state is stale or unreadable.
|
// This prevents killing healthy sessions when bead state is stale or unreadable.
|
||||||
hasSession, sessionErr := d.tmux.HasSession(DeaconSessionName)
|
deaconSession := d.getDeaconSessionName()
|
||||||
|
hasSession, sessionErr := d.tmux.HasSession(deaconSession)
|
||||||
if sessionErr == nil && hasSession {
|
if sessionErr == nil && hasSession {
|
||||||
if d.tmux.IsClaudeRunning(DeaconSessionName) {
|
if d.tmux.IsClaudeRunning(deaconSession) {
|
||||||
d.logger.Println("Deacon session healthy (Claude running), skipping restart despite stale bead")
|
d.logger.Println("Deacon session healthy (Claude running), skipping restart despite stale bead")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -291,19 +302,20 @@ func (d *Daemon) ensureDeaconRunning() {
|
|||||||
// Create session in deacon directory (ensures correct CLAUDE.md is loaded)
|
// Create session in deacon directory (ensures correct CLAUDE.md is loaded)
|
||||||
// Use EnsureSessionFresh to handle zombie sessions that exist but have dead Claude
|
// Use EnsureSessionFresh to handle zombie sessions that exist but have dead Claude
|
||||||
deaconDir := filepath.Join(d.config.TownRoot, "deacon")
|
deaconDir := filepath.Join(d.config.TownRoot, "deacon")
|
||||||
if err := d.tmux.EnsureSessionFresh(DeaconSessionName, deaconDir); err != nil {
|
sessionName := d.getDeaconSessionName()
|
||||||
|
if err := d.tmux.EnsureSessionFresh(sessionName, deaconDir); err != nil {
|
||||||
d.logger.Printf("Error creating Deacon session: %v", err)
|
d.logger.Printf("Error creating Deacon session: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set environment (non-fatal: session works without these)
|
// Set environment (non-fatal: session works without these)
|
||||||
_ = d.tmux.SetEnvironment(DeaconSessionName, "GT_ROLE", "deacon")
|
_ = d.tmux.SetEnvironment(sessionName, "GT_ROLE", "deacon")
|
||||||
_ = d.tmux.SetEnvironment(DeaconSessionName, "BD_ACTOR", "deacon")
|
_ = d.tmux.SetEnvironment(sessionName, "BD_ACTOR", "deacon")
|
||||||
|
|
||||||
// Launch Claude directly (no shell respawn loop)
|
// Launch Claude directly (no shell respawn loop)
|
||||||
// The daemon will detect if Claude exits and restart it on next heartbeat
|
// The daemon will detect if Claude exits and restart it on next heartbeat
|
||||||
// Export GT_ROLE and BD_ACTOR so Claude inherits them (tmux SetEnvironment doesn't export to processes)
|
// Export GT_ROLE and BD_ACTOR so Claude inherits them (tmux SetEnvironment doesn't export to processes)
|
||||||
if err := d.tmux.SendKeys(DeaconSessionName, config.BuildAgentStartupCommand("deacon", "deacon", "", "")); err != nil {
|
if err := d.tmux.SendKeys(sessionName, config.BuildAgentStartupCommand("deacon", "deacon", "", "")); err != nil {
|
||||||
d.logger.Printf("Error launching Claude in Deacon session: %v", err)
|
d.logger.Printf("Error launching Claude in Deacon session: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -331,8 +343,10 @@ func (d *Daemon) checkDeaconHeartbeat() {
|
|||||||
|
|
||||||
d.logger.Printf("Deacon heartbeat is stale (%s old), checking session...", age.Round(time.Minute))
|
d.logger.Printf("Deacon heartbeat is stale (%s old), checking session...", age.Round(time.Minute))
|
||||||
|
|
||||||
|
sessionName := d.getDeaconSessionName()
|
||||||
|
|
||||||
// Check if session exists
|
// Check if session exists
|
||||||
hasSession, err := d.tmux.HasSession(DeaconSessionName)
|
hasSession, err := d.tmux.HasSession(sessionName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
d.logger.Printf("Error checking Deacon session: %v", err)
|
d.logger.Printf("Error checking Deacon session: %v", err)
|
||||||
return
|
return
|
||||||
@@ -347,14 +361,14 @@ func (d *Daemon) checkDeaconHeartbeat() {
|
|||||||
if age > 30*time.Minute {
|
if age > 30*time.Minute {
|
||||||
// Very stuck - restart the session
|
// Very stuck - restart the session
|
||||||
d.logger.Printf("Deacon stuck for %s - restarting session", age.Round(time.Minute))
|
d.logger.Printf("Deacon stuck for %s - restarting session", age.Round(time.Minute))
|
||||||
if err := d.tmux.KillSession(DeaconSessionName); err != nil {
|
if err := d.tmux.KillSession(sessionName); err != nil {
|
||||||
d.logger.Printf("Error killing stuck Deacon: %v", err)
|
d.logger.Printf("Error killing stuck Deacon: %v", err)
|
||||||
}
|
}
|
||||||
// ensureDeaconRunning will be called next heartbeat to restart
|
// ensureDeaconRunning will be called next heartbeat to restart
|
||||||
} else {
|
} else {
|
||||||
// Stuck but not critically - nudge to wake up
|
// Stuck but not critically - nudge to wake up
|
||||||
d.logger.Printf("Deacon stuck for %s - nudging session", age.Round(time.Minute))
|
d.logger.Printf("Deacon stuck for %s - nudging session", age.Round(time.Minute))
|
||||||
if err := d.tmux.NudgeSession(DeaconSessionName, "HEALTH_CHECK: heartbeat stale, respond to confirm responsiveness"); err != nil {
|
if err := d.tmux.NudgeSession(sessionName, "HEALTH_CHECK: heartbeat stale, respond to confirm responsiveness"); err != nil {
|
||||||
d.logger.Printf("Error nudging stuck Deacon: %v", err)
|
d.logger.Printf("Error nudging stuck Deacon: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ import (
|
|||||||
"github.com/steveyegge/gastown/internal/beads"
|
"github.com/steveyegge/gastown/internal/beads"
|
||||||
"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/session"
|
||||||
"github.com/steveyegge/gastown/internal/tmux"
|
"github.com/steveyegge/gastown/internal/tmux"
|
||||||
|
"github.com/steveyegge/gastown/internal/workspace"
|
||||||
)
|
)
|
||||||
|
|
||||||
// BeadsMessage represents a message from gt mail inbox --json.
|
// BeadsMessage represents a message from gt mail inbox --json.
|
||||||
@@ -310,8 +312,16 @@ func (d *Daemon) identityToSession(identity string) string {
|
|||||||
|
|
||||||
// Fallback: use default patterns based on role type
|
// Fallback: use default patterns based on role type
|
||||||
switch parsed.RoleType {
|
switch parsed.RoleType {
|
||||||
case "mayor", "deacon":
|
case "mayor":
|
||||||
return "gt-" + parsed.RoleType
|
if townName, err := workspace.GetTownName(d.config.TownRoot); err == nil {
|
||||||
|
return session.MayorSessionName(townName)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
case "deacon":
|
||||||
|
if townName, err := workspace.GetTownName(d.config.TownRoot); err == nil {
|
||||||
|
return session.DeaconSessionName(townName)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
case "witness", "refinery":
|
case "witness", "refinery":
|
||||||
return fmt.Sprintf("gt-%s-%s", parsed.RigName, parsed.RoleType)
|
return fmt.Sprintf("gt-%s-%s", parsed.RigName, parsed.RoleType)
|
||||||
case "crew":
|
case "crew":
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package daemon
|
|||||||
import (
|
import (
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -14,6 +16,33 @@ func testDaemon() *Daemon {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// testDaemonWithTown creates a Daemon with a proper town setup for testing.
|
||||||
|
// Returns the daemon and a cleanup function.
|
||||||
|
func testDaemonWithTown(t *testing.T, townName string) (*Daemon, func()) {
|
||||||
|
t.Helper()
|
||||||
|
townRoot := t.TempDir()
|
||||||
|
|
||||||
|
// Create mayor directory and town.json
|
||||||
|
mayorDir := filepath.Join(townRoot, "mayor")
|
||||||
|
if err := os.MkdirAll(mayorDir, 0755); err != nil {
|
||||||
|
t.Fatalf("failed to create mayor dir: %v", err)
|
||||||
|
}
|
||||||
|
townJSON := filepath.Join(mayorDir, "town.json")
|
||||||
|
content := `{"name": "` + townName + `"}`
|
||||||
|
if err := os.WriteFile(townJSON, []byte(content), 0644); err != nil {
|
||||||
|
t.Fatalf("failed to write town.json: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
d := &Daemon{
|
||||||
|
config: &Config{TownRoot: townRoot},
|
||||||
|
logger: log.New(io.Discard, "", 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
return d, func() {
|
||||||
|
// Cleanup handled by t.TempDir()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestParseLifecycleRequest_Cycle(t *testing.T) {
|
func TestParseLifecycleRequest_Cycle(t *testing.T) {
|
||||||
d := testDaemon()
|
d := testDaemon()
|
||||||
|
|
||||||
@@ -152,11 +181,12 @@ func TestParseLifecycleRequest_AlwaysUsesFromField(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestIdentityToSession_Mayor(t *testing.T) {
|
func TestIdentityToSession_Mayor(t *testing.T) {
|
||||||
d := testDaemon()
|
d, cleanup := testDaemonWithTown(t, "ai")
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
result := d.identityToSession("mayor")
|
result := d.identityToSession("mayor")
|
||||||
if result != "gt-mayor" {
|
if result != "gt-ai-mayor" {
|
||||||
t.Errorf("identityToSession('mayor') = %q, expected 'gt-mayor'", result)
|
t.Errorf("identityToSession('mayor') = %q, expected 'gt-ai-mayor'", result)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/steveyegge/gastown/internal/session"
|
||||||
|
"github.com/steveyegge/gastown/internal/workspace"
|
||||||
)
|
)
|
||||||
|
|
||||||
// LifecycleHygieneCheck detects and cleans up stale lifecycle state.
|
// LifecycleHygieneCheck detects and cleans up stale lifecycle state.
|
||||||
@@ -139,7 +142,7 @@ func (c *LifecycleHygieneCheck) checkStateFiles(ctx *CheckContext) int {
|
|||||||
if strings.HasPrefix(key, "requesting_") {
|
if strings.HasPrefix(key, "requesting_") {
|
||||||
if boolVal, ok := val.(bool); ok && boolVal {
|
if boolVal, ok := val.(bool); ok && boolVal {
|
||||||
// Found a stuck flag - verify session is actually healthy
|
// Found a stuck flag - verify session is actually healthy
|
||||||
if c.isSessionHealthy(sf.identity) {
|
if c.isSessionHealthy(sf.identity, ctx.TownRoot) {
|
||||||
c.stuckStateFiles = append(c.stuckStateFiles, stuckState{
|
c.stuckStateFiles = append(c.stuckStateFiles, stuckState{
|
||||||
stateFile: sf.path,
|
stateFile: sf.path,
|
||||||
identity: sf.identity,
|
identity: sf.identity,
|
||||||
@@ -225,8 +228,8 @@ func (c *LifecycleHygieneCheck) findStateFiles(townRoot string) []stateFileInfo
|
|||||||
}
|
}
|
||||||
|
|
||||||
// isSessionHealthy checks if the tmux session for this identity exists and is running.
|
// isSessionHealthy checks if the tmux session for this identity exists and is running.
|
||||||
func (c *LifecycleHygieneCheck) isSessionHealthy(identity string) bool {
|
func (c *LifecycleHygieneCheck) isSessionHealthy(identity, townRoot string) bool {
|
||||||
sessionName := identityToSessionName(identity)
|
sessionName := identityToSessionName(identity, townRoot)
|
||||||
if sessionName == "" {
|
if sessionName == "" {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -237,10 +240,15 @@ func (c *LifecycleHygieneCheck) isSessionHealthy(identity string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// identityToSessionName converts an identity to its tmux session name.
|
// identityToSessionName converts an identity to its tmux session name.
|
||||||
func identityToSessionName(identity string) string {
|
func identityToSessionName(identity, townRoot string) string {
|
||||||
switch identity {
|
switch identity {
|
||||||
case "mayor":
|
case "mayor":
|
||||||
return "gt-mayor"
|
if townRoot != "" {
|
||||||
|
if townName, err := workspace.GetTownName(townRoot); err == nil {
|
||||||
|
return session.MayorSessionName(townName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "" // Cannot generate session name without town root
|
||||||
default:
|
default:
|
||||||
if strings.HasSuffix(identity, "-witness") ||
|
if strings.HasSuffix(identity, "-witness") ||
|
||||||
strings.HasSuffix(identity, "-refinery") ||
|
strings.HasSuffix(identity, "-refinery") ||
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/steveyegge/gastown/internal/session"
|
||||||
"github.com/steveyegge/gastown/internal/tmux"
|
"github.com/steveyegge/gastown/internal/tmux"
|
||||||
|
"github.com/steveyegge/gastown/internal/workspace"
|
||||||
)
|
)
|
||||||
|
|
||||||
// OrphanSessionCheck detects orphaned tmux sessions that don't match
|
// OrphanSessionCheck detects orphaned tmux sessions that don't match
|
||||||
@@ -55,24 +57,31 @@ func (c *OrphanSessionCheck) Run(ctx *CheckContext) *CheckResult {
|
|||||||
// Get list of valid rigs
|
// Get list of valid rigs
|
||||||
validRigs := c.getValidRigs(ctx.TownRoot)
|
validRigs := c.getValidRigs(ctx.TownRoot)
|
||||||
|
|
||||||
|
// Get dynamic session names for mayor/deacon
|
||||||
|
var mayorSession, deaconSession string
|
||||||
|
if townName, err := workspace.GetTownName(ctx.TownRoot); err == nil {
|
||||||
|
mayorSession = session.MayorSessionName(townName)
|
||||||
|
deaconSession = session.DeaconSessionName(townName)
|
||||||
|
}
|
||||||
|
|
||||||
// Check each session
|
// Check each session
|
||||||
var orphans []string
|
var orphans []string
|
||||||
var validCount int
|
var validCount int
|
||||||
|
|
||||||
for _, session := range sessions {
|
for _, sess := range sessions {
|
||||||
if session == "" {
|
if sess == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only check gt-* sessions (Gas Town sessions)
|
// Only check gt-* sessions (Gas Town sessions)
|
||||||
if !strings.HasPrefix(session, "gt-") {
|
if !strings.HasPrefix(sess, "gt-") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if c.isValidSession(session, validRigs) {
|
if c.isValidSession(sess, validRigs, mayorSession, deaconSession) {
|
||||||
validCount++
|
validCount++
|
||||||
} else {
|
} else {
|
||||||
orphans = append(orphans, session)
|
orphans = append(orphans, sess)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,27 +175,27 @@ func (c *OrphanSessionCheck) getValidRigs(townRoot string) []string {
|
|||||||
|
|
||||||
// isValidSession checks if a session name matches expected Gas Town patterns.
|
// isValidSession checks if a session name matches expected Gas Town patterns.
|
||||||
// Valid patterns:
|
// Valid patterns:
|
||||||
// - gt-mayor
|
// - gt-{town}-mayor (dynamic based on town name)
|
||||||
// - gt-deacon
|
// - gt-{town}-deacon (dynamic based on town name)
|
||||||
// - gt-<rig>-witness
|
// - gt-<rig>-witness
|
||||||
// - gt-<rig>-refinery
|
// - gt-<rig>-refinery
|
||||||
// - gt-<rig>-<polecat> (where polecat is any name)
|
// - gt-<rig>-<polecat> (where polecat is any name)
|
||||||
//
|
//
|
||||||
// Note: We can't verify polecat names without reading state, so we're permissive.
|
// Note: We can't verify polecat names without reading state, so we're permissive.
|
||||||
func (c *OrphanSessionCheck) isValidSession(session string, validRigs []string) bool {
|
func (c *OrphanSessionCheck) isValidSession(sess string, validRigs []string, mayorSession, deaconSession string) bool {
|
||||||
// gt-mayor is always valid
|
// Mayor session is always valid (dynamic name based on town)
|
||||||
if session == "gt-mayor" {
|
if mayorSession != "" && sess == mayorSession {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// gt-deacon is always valid
|
// Deacon session is always valid (dynamic name based on town)
|
||||||
if session == "gt-deacon" {
|
if deaconSession != "" && sess == deaconSession {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// For rig-specific sessions, extract rig name
|
// For rig-specific sessions, extract rig name
|
||||||
// Pattern: gt-<rig>-<role>
|
// Pattern: gt-<rig>-<role>
|
||||||
parts := strings.SplitN(session, "-", 3)
|
parts := strings.SplitN(sess, "-", 3)
|
||||||
if len(parts) < 3 {
|
if len(parts) < 3 {
|
||||||
// Invalid format - must be gt-<rig>-<something>
|
// Invalid format - must be gt-<rig>-<something>
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/steveyegge/gastown/internal/session"
|
||||||
"github.com/steveyegge/gastown/internal/tmux"
|
"github.com/steveyegge/gastown/internal/tmux"
|
||||||
|
"github.com/steveyegge/gastown/internal/workspace"
|
||||||
)
|
)
|
||||||
|
|
||||||
// LinkedPaneCheck detects tmux sessions that share panes,
|
// LinkedPaneCheck detects tmux sessions that share panes,
|
||||||
@@ -83,11 +85,17 @@ func (c *LinkedPaneCheck) Run(ctx *CheckContext) *CheckResult {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache for Fix (exclude gt-mayor since we don't want to kill it)
|
// Cache for Fix (exclude mayor session since we don't want to kill it)
|
||||||
|
// Get dynamic mayor session name
|
||||||
|
var mayorSession string
|
||||||
|
if townName, err := workspace.GetTownName(ctx.TownRoot); err == nil {
|
||||||
|
mayorSession = session.MayorSessionName(townName)
|
||||||
|
}
|
||||||
|
|
||||||
c.linkedSessions = nil
|
c.linkedSessions = nil
|
||||||
for session := range linkedSessionSet {
|
for sess := range linkedSessionSet {
|
||||||
if session != "gt-mayor" {
|
if mayorSession == "" || sess != mayorSession {
|
||||||
c.linkedSessions = append(c.linkedSessions, session)
|
c.linkedSessions = append(c.linkedSessions, sess)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,7 +116,7 @@ func (c *LinkedPaneCheck) Run(ctx *CheckContext) *CheckResult {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fix kills sessions with linked panes (except gt-mayor).
|
// Fix kills sessions with linked panes (except mayor session).
|
||||||
// The daemon will recreate them with independent panes.
|
// The daemon will recreate them with independent panes.
|
||||||
func (c *LinkedPaneCheck) Fix(ctx *CheckContext) error {
|
func (c *LinkedPaneCheck) Fix(ctx *CheckContext) error {
|
||||||
if len(c.linkedSessions) == 0 {
|
if len(c.linkedSessions) == 0 {
|
||||||
|
|||||||
+22
-3
@@ -11,7 +11,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/steveyegge/gastown/internal/config"
|
"github.com/steveyegge/gastown/internal/config"
|
||||||
|
"github.com/steveyegge/gastown/internal/session"
|
||||||
"github.com/steveyegge/gastown/internal/tmux"
|
"github.com/steveyegge/gastown/internal/tmux"
|
||||||
|
"github.com/steveyegge/gastown/internal/workspace"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrUnknownList indicates a mailing list name was not found in configuration.
|
// ErrUnknownList indicates a mailing list name was not found in configuration.
|
||||||
@@ -934,7 +936,13 @@ func (r *Router) GetMailbox(address string) (*Mailbox, error) {
|
|||||||
// Uses send-keys to echo a visible banner to ensure notification is seen.
|
// Uses send-keys to echo a visible banner to ensure notification is seen.
|
||||||
// Supports mayor/, rig/polecat, and rig/refinery addresses.
|
// Supports mayor/, rig/polecat, and rig/refinery addresses.
|
||||||
func (r *Router) notifyRecipient(msg *Message) error {
|
func (r *Router) notifyRecipient(msg *Message) error {
|
||||||
sessionID := addressToSessionID(msg.To)
|
// Get town name for session name generation
|
||||||
|
var townName string
|
||||||
|
if r.townRoot != "" {
|
||||||
|
townName, _ = workspace.GetTownName(r.townRoot)
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionID := addressToSessionID(msg.To, townName)
|
||||||
if sessionID == "" {
|
if sessionID == "" {
|
||||||
return nil // Unable to determine session ID
|
return nil // Unable to determine session ID
|
||||||
}
|
}
|
||||||
@@ -951,10 +959,21 @@ func (r *Router) notifyRecipient(msg *Message) error {
|
|||||||
|
|
||||||
// addressToSessionID converts a mail address to a tmux session ID.
|
// addressToSessionID converts a mail address to a tmux session ID.
|
||||||
// Returns empty string if address format is not recognized.
|
// Returns empty string if address format is not recognized.
|
||||||
func addressToSessionID(address string) string {
|
func addressToSessionID(address, townName string) string {
|
||||||
// Mayor address: "mayor/" or "mayor"
|
// Mayor address: "mayor/" or "mayor"
|
||||||
if strings.HasPrefix(address, "mayor") {
|
if strings.HasPrefix(address, "mayor") {
|
||||||
return "gt-mayor"
|
if townName != "" {
|
||||||
|
return session.MayorSessionName(townName)
|
||||||
|
}
|
||||||
|
return "" // Cannot generate session name without town name
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deacon address: "deacon/" or "deacon"
|
||||||
|
if strings.HasPrefix(address, "deacon") {
|
||||||
|
if townName != "" {
|
||||||
|
return session.DeaconSessionName(townName)
|
||||||
|
}
|
||||||
|
return "" // Cannot generate session name without town name
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rig-based address: "rig/target"
|
// Rig-based address: "rig/target"
|
||||||
|
|||||||
@@ -87,12 +87,14 @@ func TestIsTownLevelAddress(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAddressToSessionID(t *testing.T) {
|
func TestAddressToSessionID(t *testing.T) {
|
||||||
|
townName := "ai"
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
address string
|
address string
|
||||||
want string
|
want string
|
||||||
}{
|
}{
|
||||||
{"mayor", "gt-mayor"},
|
{"mayor", "gt-ai-mayor"},
|
||||||
{"mayor/", "gt-mayor"},
|
{"mayor/", "gt-ai-mayor"},
|
||||||
|
{"deacon", "gt-ai-deacon"},
|
||||||
{"gastown/refinery", "gt-gastown-refinery"},
|
{"gastown/refinery", "gt-gastown-refinery"},
|
||||||
{"gastown/Toast", "gt-gastown-Toast"},
|
{"gastown/Toast", "gt-gastown-Toast"},
|
||||||
{"beads/witness", "gt-beads-witness"},
|
{"beads/witness", "gt-beads-witness"},
|
||||||
@@ -103,9 +105,9 @@ func TestAddressToSessionID(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.address, func(t *testing.T) {
|
t.Run(tt.address, func(t *testing.T) {
|
||||||
got := addressToSessionID(tt.address)
|
got := addressToSessionID(tt.address, townName)
|
||||||
if got != tt.want {
|
if got != tt.want {
|
||||||
t.Errorf("addressToSessionID(%q) = %q, want %q", tt.address, got, tt.want)
|
t.Errorf("addressToSessionID(%q, %q) = %q, want %q", tt.address, townName, got, tt.want)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-5
@@ -14,6 +14,7 @@ import (
|
|||||||
"github.com/steveyegge/gastown/internal/config"
|
"github.com/steveyegge/gastown/internal/config"
|
||||||
"github.com/steveyegge/gastown/internal/git"
|
"github.com/steveyegge/gastown/internal/git"
|
||||||
"github.com/steveyegge/gastown/internal/templates"
|
"github.com/steveyegge/gastown/internal/templates"
|
||||||
|
"github.com/steveyegge/gastown/internal/workspace"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Common errors
|
// Common errors
|
||||||
@@ -794,12 +795,18 @@ func (m *Manager) createRoleCLAUDEmd(workspacePath string, role string, rigName
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get town name for session names
|
||||||
|
townName, _ := workspace.GetTownName(m.townRoot)
|
||||||
|
|
||||||
data := templates.RoleData{
|
data := templates.RoleData{
|
||||||
Role: role,
|
Role: role,
|
||||||
RigName: rigName,
|
RigName: rigName,
|
||||||
TownRoot: m.townRoot,
|
TownRoot: m.townRoot,
|
||||||
WorkDir: workspacePath,
|
TownName: townName,
|
||||||
Polecat: workerName, // Used for crew member name as well
|
WorkDir: workspacePath,
|
||||||
|
Polecat: workerName, // Used for crew member name as well
|
||||||
|
MayorSession: fmt.Sprintf("gt-%s-mayor", townName),
|
||||||
|
DeaconSession: fmt.Sprintf("gt-%s-deacon", townName),
|
||||||
}
|
}
|
||||||
|
|
||||||
content, err := tmpl.RenderRole(role, data)
|
content, err := tmpl.RenderRole(role, data)
|
||||||
|
|||||||
@@ -21,15 +21,16 @@ const (
|
|||||||
// AgentIdentity represents a parsed Gas Town agent identity.
|
// AgentIdentity represents a parsed Gas Town agent identity.
|
||||||
type AgentIdentity struct {
|
type AgentIdentity struct {
|
||||||
Role Role // mayor, deacon, witness, refinery, crew, polecat
|
Role Role // mayor, deacon, witness, refinery, crew, polecat
|
||||||
Rig string // empty for mayor/deacon
|
Town string // town name (for mayor/deacon only)
|
||||||
|
Rig string // rig name (empty for mayor/deacon)
|
||||||
Name string // crew/polecat name (empty for mayor/deacon/witness/refinery)
|
Name string // crew/polecat name (empty for mayor/deacon/witness/refinery)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseSessionName parses a tmux session name into an AgentIdentity.
|
// ParseSessionName parses a tmux session name into an AgentIdentity.
|
||||||
//
|
//
|
||||||
// Session name formats:
|
// Session name formats:
|
||||||
// - gt-mayor → Role: mayor
|
// - gt-<town>-mayor → Role: mayor, Town: <town>
|
||||||
// - gt-deacon → Role: deacon
|
// - gt-<town>-deacon → Role: deacon, Town: <town>
|
||||||
// - gt-<rig>-witness → Role: witness, Rig: <rig>
|
// - gt-<rig>-witness → Role: witness, Rig: <rig>
|
||||||
// - gt-<rig>-refinery → Role: refinery, Rig: <rig>
|
// - gt-<rig>-refinery → Role: refinery, Rig: <rig>
|
||||||
// - gt-<rig>-crew-<name> → Role: crew, Rig: <rig>, Name: <name>
|
// - gt-<rig>-crew-<name> → Role: crew, Rig: <rig>, Name: <name>
|
||||||
@@ -48,18 +49,20 @@ func ParseSessionName(session string) (*AgentIdentity, error) {
|
|||||||
return nil, fmt.Errorf("invalid session name %q: empty after prefix", session)
|
return nil, fmt.Errorf("invalid session name %q: empty after prefix", session)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for global roles first (no rig)
|
// Parse into parts
|
||||||
switch suffix {
|
|
||||||
case "mayor":
|
|
||||||
return &AgentIdentity{Role: RoleMayor}, nil
|
|
||||||
case "deacon":
|
|
||||||
return &AgentIdentity{Role: RoleDeacon}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse rig-based roles
|
|
||||||
parts := strings.Split(suffix, "-")
|
parts := strings.Split(suffix, "-")
|
||||||
if len(parts) < 2 {
|
if len(parts) < 2 {
|
||||||
return nil, fmt.Errorf("invalid session name %q: expected rig-role format", session)
|
return nil, fmt.Errorf("invalid session name %q: expected town-role or rig-role format", session)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for mayor/deacon (town-level roles with suffix marker)
|
||||||
|
if parts[len(parts)-1] == "mayor" {
|
||||||
|
town := strings.Join(parts[:len(parts)-1], "-")
|
||||||
|
return &AgentIdentity{Role: RoleMayor, Town: town}, nil
|
||||||
|
}
|
||||||
|
if parts[len(parts)-1] == "deacon" {
|
||||||
|
town := strings.Join(parts[:len(parts)-1], "-")
|
||||||
|
return &AgentIdentity{Role: RoleDeacon, Town: town}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for witness/refinery (suffix markers)
|
// Check for witness/refinery (suffix markers)
|
||||||
@@ -94,9 +97,9 @@ func ParseSessionName(session string) (*AgentIdentity, error) {
|
|||||||
func (a *AgentIdentity) SessionName() string {
|
func (a *AgentIdentity) SessionName() string {
|
||||||
switch a.Role {
|
switch a.Role {
|
||||||
case RoleMayor:
|
case RoleMayor:
|
||||||
return MayorSessionName()
|
return MayorSessionName(a.Town)
|
||||||
case RoleDeacon:
|
case RoleDeacon:
|
||||||
return DeaconSessionName()
|
return DeaconSessionName(a.Town)
|
||||||
case RoleWitness:
|
case RoleWitness:
|
||||||
return WitnessSessionName(a.Rig)
|
return WitnessSessionName(a.Rig)
|
||||||
case RoleRefinery:
|
case RoleRefinery:
|
||||||
|
|||||||
@@ -9,20 +9,35 @@ func TestParseSessionName(t *testing.T) {
|
|||||||
name string
|
name string
|
||||||
session string
|
session string
|
||||||
wantRole Role
|
wantRole Role
|
||||||
|
wantTown string
|
||||||
wantRig string
|
wantRig string
|
||||||
wantName string
|
wantName string
|
||||||
wantErr bool
|
wantErr bool
|
||||||
}{
|
}{
|
||||||
// Global roles (no rig)
|
// Town-level roles (mayor/deacon with town name)
|
||||||
{
|
{
|
||||||
name: "mayor",
|
name: "mayor simple town",
|
||||||
session: "gt-mayor",
|
session: "gt-ai-mayor",
|
||||||
wantRole: RoleMayor,
|
wantRole: RoleMayor,
|
||||||
|
wantTown: "ai",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "deacon",
|
name: "mayor hyphenated town",
|
||||||
session: "gt-deacon",
|
session: "gt-my-town-mayor",
|
||||||
|
wantRole: RoleMayor,
|
||||||
|
wantTown: "my-town",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "deacon simple town",
|
||||||
|
session: "gt-alpha-deacon",
|
||||||
wantRole: RoleDeacon,
|
wantRole: RoleDeacon,
|
||||||
|
wantTown: "alpha",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "deacon hyphenated town",
|
||||||
|
session: "gt-my-town-deacon",
|
||||||
|
wantRole: RoleDeacon,
|
||||||
|
wantTown: "my-town",
|
||||||
},
|
},
|
||||||
|
|
||||||
// Witness (simple rig)
|
// Witness (simple rig)
|
||||||
@@ -104,7 +119,7 @@ func TestParseSessionName(t *testing.T) {
|
|||||||
wantErr: true,
|
wantErr: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "just prefix",
|
name: "just prefix single segment",
|
||||||
session: "gt-x",
|
session: "gt-x",
|
||||||
wantErr: true,
|
wantErr: true,
|
||||||
},
|
},
|
||||||
@@ -123,6 +138,9 @@ func TestParseSessionName(t *testing.T) {
|
|||||||
if got.Role != tt.wantRole {
|
if got.Role != tt.wantRole {
|
||||||
t.Errorf("ParseSessionName(%q).Role = %v, want %v", tt.session, got.Role, tt.wantRole)
|
t.Errorf("ParseSessionName(%q).Role = %v, want %v", tt.session, got.Role, tt.wantRole)
|
||||||
}
|
}
|
||||||
|
if got.Town != tt.wantTown {
|
||||||
|
t.Errorf("ParseSessionName(%q).Town = %v, want %v", tt.session, got.Town, tt.wantTown)
|
||||||
|
}
|
||||||
if got.Rig != tt.wantRig {
|
if got.Rig != tt.wantRig {
|
||||||
t.Errorf("ParseSessionName(%q).Rig = %v, want %v", tt.session, got.Rig, tt.wantRig)
|
t.Errorf("ParseSessionName(%q).Rig = %v, want %v", tt.session, got.Rig, tt.wantRig)
|
||||||
}
|
}
|
||||||
@@ -140,14 +158,19 @@ func TestAgentIdentity_SessionName(t *testing.T) {
|
|||||||
want string
|
want string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "mayor",
|
name: "mayor with town",
|
||||||
identity: AgentIdentity{Role: RoleMayor},
|
identity: AgentIdentity{Role: RoleMayor, Town: "ai"},
|
||||||
want: "gt-mayor",
|
want: "gt-ai-mayor",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "deacon",
|
name: "mayor hyphenated town",
|
||||||
identity: AgentIdentity{Role: RoleDeacon},
|
identity: AgentIdentity{Role: RoleMayor, Town: "my-town"},
|
||||||
want: "gt-deacon",
|
want: "gt-my-town-mayor",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "deacon with town",
|
||||||
|
identity: AgentIdentity{Role: RoleDeacon, Town: "alpha"},
|
||||||
|
want: "gt-alpha-deacon",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "witness",
|
name: "witness",
|
||||||
@@ -230,22 +253,22 @@ func TestAgentIdentity_Address(t *testing.T) {
|
|||||||
func TestParseSessionName_RoundTrip(t *testing.T) {
|
func TestParseSessionName_RoundTrip(t *testing.T) {
|
||||||
// Test that parsing then reconstructing gives the same result
|
// Test that parsing then reconstructing gives the same result
|
||||||
sessions := []string{
|
sessions := []string{
|
||||||
"gt-mayor",
|
"gt-ai-mayor",
|
||||||
"gt-deacon",
|
"gt-alpha-deacon",
|
||||||
"gt-gastown-witness",
|
"gt-gastown-witness",
|
||||||
"gt-foo-bar-refinery",
|
"gt-foo-bar-refinery",
|
||||||
"gt-gastown-crew-max",
|
"gt-gastown-crew-max",
|
||||||
"gt-gastown-morsov",
|
"gt-gastown-morsov",
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, session := range sessions {
|
for _, sess := range sessions {
|
||||||
t.Run(session, func(t *testing.T) {
|
t.Run(sess, func(t *testing.T) {
|
||||||
identity, err := ParseSessionName(session)
|
identity, err := ParseSessionName(sess)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ParseSessionName(%q) error = %v", session, err)
|
t.Fatalf("ParseSessionName(%q) error = %v", sess, err)
|
||||||
}
|
}
|
||||||
if got := identity.SessionName(); got != session {
|
if got := identity.SessionName(); got != sess {
|
||||||
t.Errorf("Round-trip failed: ParseSessionName(%q).SessionName() = %q", session, got)
|
t.Errorf("Round-trip failed: ParseSessionName(%q).SessionName() = %q", sess, got)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,13 +12,17 @@ import (
|
|||||||
const Prefix = "gt-"
|
const Prefix = "gt-"
|
||||||
|
|
||||||
// MayorSessionName returns the session name for the Mayor agent.
|
// MayorSessionName returns the session name for the Mayor agent.
|
||||||
func MayorSessionName() string {
|
// The townName parameter allows multiple towns to run concurrently
|
||||||
return Prefix + "mayor"
|
// without tmux session name collisions.
|
||||||
|
func MayorSessionName(townName string) string {
|
||||||
|
return fmt.Sprintf("%s%s-mayor", Prefix, townName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeaconSessionName returns the session name for the Deacon agent.
|
// DeaconSessionName returns the session name for the Deacon agent.
|
||||||
func DeaconSessionName() string {
|
// The townName parameter allows multiple towns to run concurrently
|
||||||
return Prefix + "deacon"
|
// without tmux session name collisions.
|
||||||
|
func DeaconSessionName(townName string) string {
|
||||||
|
return fmt.Sprintf("%s%s-deacon", Prefix, townName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// WitnessSessionName returns the session name for a rig's Witness agent.
|
// WitnessSessionName returns the session name for a rig's Witness agent.
|
||||||
|
|||||||
@@ -8,18 +8,42 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestMayorSessionName(t *testing.T) {
|
func TestMayorSessionName(t *testing.T) {
|
||||||
want := "gt-mayor"
|
tests := []struct {
|
||||||
got := MayorSessionName()
|
townName string
|
||||||
if got != want {
|
want string
|
||||||
t.Errorf("MayorSessionName() = %q, want %q", got, want)
|
}{
|
||||||
|
{"ai", "gt-ai-mayor"},
|
||||||
|
{"alpha", "gt-alpha-mayor"},
|
||||||
|
{"gastown", "gt-gastown-mayor"},
|
||||||
|
{"", "gt--mayor"}, // empty town name
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.townName, func(t *testing.T) {
|
||||||
|
got := MayorSessionName(tt.townName)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("MayorSessionName(%q) = %q, want %q", tt.townName, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDeaconSessionName(t *testing.T) {
|
func TestDeaconSessionName(t *testing.T) {
|
||||||
want := "gt-deacon"
|
tests := []struct {
|
||||||
got := DeaconSessionName()
|
townName string
|
||||||
if got != want {
|
want string
|
||||||
t.Errorf("DeaconSessionName() = %q, want %q", got, want)
|
}{
|
||||||
|
{"ai", "gt-ai-deacon"},
|
||||||
|
{"alpha", "gt-alpha-deacon"},
|
||||||
|
{"gastown", "gt-gastown-deacon"},
|
||||||
|
{"", "gt--deacon"}, // empty town name
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.townName, func(t *testing.T) {
|
||||||
|
got := DeaconSessionName(tt.townName)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("DeaconSessionName(%q) = %q, want %q", tt.townName, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ Check the current system state:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Is Deacon session alive?
|
# Is Deacon session alive?
|
||||||
tmux has-session -t gt-deacon 2>/dev/null && echo "alive" || echo "dead"
|
tmux has-session -t {{ .DeaconSession }} 2>/dev/null && echo "alive" || echo "dead"
|
||||||
|
|
||||||
# If alive, what's the pane showing?
|
# If alive, what's the pane showing?
|
||||||
gt peek deacon --lines 20
|
gt peek deacon --lines 20
|
||||||
|
|||||||
@@ -244,8 +244,8 @@ This is the opposite of polecat work, which is persistent and auditable.
|
|||||||
|
|
||||||
| Role | Session Name |
|
| Role | Session Name |
|
||||||
|------|-------------|
|
|------|-------------|
|
||||||
| Deacon | `gt-deacon` (you) |
|
| Deacon | `{{ .DeaconSession }}` (you) |
|
||||||
| Mayor | `gt-mayor` |
|
| Mayor | `{{ .MayorSession }}` |
|
||||||
| Witness | `gt-<rig>-witness` |
|
| Witness | `gt-<rig>-witness` |
|
||||||
| Crew | `gt-<rig>-<name>` |
|
| Crew | `gt-<rig>-<name>` |
|
||||||
|
|
||||||
@@ -376,5 +376,5 @@ But typically just exit and let the daemon respawn you with fresh context.
|
|||||||
|
|
||||||
State directory: {{ .TownRoot }}/deacon/
|
State directory: {{ .TownRoot }}/deacon/
|
||||||
Mail identity: deacon/
|
Mail identity: deacon/
|
||||||
Session: gt-deacon
|
Session: {{ .DeaconSession }}
|
||||||
Patrol molecule: mol-deacon-patrol (created as wisp)
|
Patrol molecule: mol-deacon-patrol (created as wisp)
|
||||||
|
|||||||
@@ -24,14 +24,17 @@ type Templates struct {
|
|||||||
|
|
||||||
// RoleData contains information for rendering role contexts.
|
// RoleData contains information for rendering role contexts.
|
||||||
type RoleData struct {
|
type RoleData struct {
|
||||||
Role string // mayor, witness, refinery, polecat, crew, deacon
|
Role string // mayor, witness, refinery, polecat, crew, deacon
|
||||||
RigName string // e.g., "greenplace"
|
RigName string // e.g., "greenplace"
|
||||||
TownRoot string // e.g., "/Users/steve/ai"
|
TownRoot string // e.g., "/Users/steve/ai"
|
||||||
WorkDir string // current working directory
|
TownName string // e.g., "ai" - the town identifier for session names
|
||||||
Polecat string // polecat name (for polecat role)
|
WorkDir string // current working directory
|
||||||
Polecats []string // list of polecats (for witness role)
|
Polecat string // polecat name (for polecat role)
|
||||||
BeadsDir string // BEADS_DIR path
|
Polecats []string // list of polecats (for witness role)
|
||||||
IssuePrefix string // beads issue prefix
|
BeadsDir string // BEADS_DIR path
|
||||||
|
IssuePrefix string // beads issue prefix
|
||||||
|
MayorSession string // e.g., "gt-ai-mayor" - dynamic mayor session name
|
||||||
|
DeaconSession string // e.g., "gt-ai-deacon" - dynamic deacon session name
|
||||||
}
|
}
|
||||||
|
|
||||||
// SpawnData contains information for spawn assignment messages.
|
// SpawnData contains information for spawn assignment messages.
|
||||||
|
|||||||
@@ -22,9 +22,12 @@ func TestRenderRole_Mayor(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
data := RoleData{
|
data := RoleData{
|
||||||
Role: "mayor",
|
Role: "mayor",
|
||||||
TownRoot: "/test/town",
|
TownRoot: "/test/town",
|
||||||
WorkDir: "/test/town",
|
TownName: "town",
|
||||||
|
WorkDir: "/test/town",
|
||||||
|
MayorSession: "gt-town-mayor",
|
||||||
|
DeaconSession: "gt-town-deacon",
|
||||||
}
|
}
|
||||||
|
|
||||||
output, err := tmpl.RenderRole("mayor", data)
|
output, err := tmpl.RenderRole("mayor", data)
|
||||||
@@ -51,11 +54,14 @@ func TestRenderRole_Polecat(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
data := RoleData{
|
data := RoleData{
|
||||||
Role: "polecat",
|
Role: "polecat",
|
||||||
RigName: "myrig",
|
RigName: "myrig",
|
||||||
TownRoot: "/test/town",
|
TownRoot: "/test/town",
|
||||||
WorkDir: "/test/town/myrig/polecats/TestCat",
|
TownName: "town",
|
||||||
Polecat: "TestCat",
|
WorkDir: "/test/town/myrig/polecats/TestCat",
|
||||||
|
Polecat: "TestCat",
|
||||||
|
MayorSession: "gt-town-mayor",
|
||||||
|
DeaconSession: "gt-town-deacon",
|
||||||
}
|
}
|
||||||
|
|
||||||
output, err := tmpl.RenderRole("polecat", data)
|
output, err := tmpl.RenderRole("polecat", data)
|
||||||
@@ -82,9 +88,12 @@ func TestRenderRole_Deacon(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
data := RoleData{
|
data := RoleData{
|
||||||
Role: "deacon",
|
Role: "deacon",
|
||||||
TownRoot: "/test/town",
|
TownRoot: "/test/town",
|
||||||
WorkDir: "/test/town",
|
TownName: "town",
|
||||||
|
WorkDir: "/test/town",
|
||||||
|
MayorSession: "gt-town-mayor",
|
||||||
|
DeaconSession: "gt-town-deacon",
|
||||||
}
|
}
|
||||||
|
|
||||||
output, err := tmpl.RenderRole("deacon", data)
|
output, err := tmpl.RenderRole("deacon", data)
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/steveyegge/gastown/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrNotFound indicates no workspace was found.
|
// ErrNotFound indicates no workspace was found.
|
||||||
@@ -126,3 +128,35 @@ func IsWorkspace(dir string) (bool, error) {
|
|||||||
|
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetTownName loads the town name from the workspace's town.json config.
|
||||||
|
// This is used for generating unique tmux session names that avoid collisions
|
||||||
|
// when running multiple Gas Town instances.
|
||||||
|
func GetTownName(townRoot string) (string, error) {
|
||||||
|
townConfigPath := filepath.Join(townRoot, PrimaryMarker)
|
||||||
|
townConfig, err := config.LoadTownConfig(townConfigPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("loading town config: %w", err)
|
||||||
|
}
|
||||||
|
return townConfig.Name, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTownNameFromCwd locates the town root from the current working directory
|
||||||
|
// and returns the town name from its configuration.
|
||||||
|
func GetTownNameFromCwd() (string, error) {
|
||||||
|
townRoot, err := FindFromCwdOrError()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return GetTownName(townRoot)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MustGetTownName returns the town name or panics if it cannot be loaded.
|
||||||
|
// Use sparingly - prefer GetTownName with proper error handling.
|
||||||
|
func MustGetTownName(townRoot string) string {
|
||||||
|
name, err := GetTownName(townRoot)
|
||||||
|
if err != nil {
|
||||||
|
panic(fmt.Sprintf("failed to get town name: %v", err))
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user