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:
@@ -21,8 +21,19 @@ import (
|
||||
"github.com/steveyegge/gastown/internal/workspace"
|
||||
)
|
||||
|
||||
// DeaconSessionName is the tmux session name for the Deacon.
|
||||
const DeaconSessionName = "gt-deacon"
|
||||
// getDeaconSessionName returns the Deacon session name for the current workspace.
|
||||
// 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{
|
||||
Use: "deacon",
|
||||
@@ -274,8 +285,13 @@ func init() {
|
||||
func runDeaconStart(cmd *cobra.Command, args []string) error {
|
||||
t := tmux.NewTmux()
|
||||
|
||||
sessionName, err := getDeaconSessionName()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check if session already exists
|
||||
running, err := t.HasSession(DeaconSessionName)
|
||||
running, err := t.HasSession(sessionName)
|
||||
if err != nil {
|
||||
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")
|
||||
}
|
||||
|
||||
if err := startDeaconSession(t); err != nil {
|
||||
if err := startDeaconSession(t, sessionName); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -295,7 +311,7 @@ func runDeaconStart(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
|
||||
// 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
|
||||
townRoot, err := workspace.FindFromCwdOrError()
|
||||
if err != nil {
|
||||
@@ -317,35 +333,35 @@ func startDeaconSession(t *tmux.Tmux) error {
|
||||
|
||||
// Create session in deacon directory
|
||||
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)
|
||||
}
|
||||
|
||||
// Set environment (non-fatal: session works without these)
|
||||
_ = t.SetEnvironment(DeaconSessionName, "GT_ROLE", "deacon")
|
||||
_ = t.SetEnvironment(DeaconSessionName, "BD_ACTOR", "deacon")
|
||||
_ = t.SetEnvironment(sessionName, "GT_ROLE", "deacon")
|
||||
_ = t.SetEnvironment(sessionName, "BD_ACTOR", "deacon")
|
||||
|
||||
// Apply Deacon theme (non-fatal: theming failure doesn't affect operation)
|
||||
// Note: ConfigureGasTownSession includes cycle bindings
|
||||
theme := tmux.DeaconTheme()
|
||||
_ = t.ConfigureGasTownSession(DeaconSessionName, theme, "", "Deacon", "health-check")
|
||||
_ = t.ConfigureGasTownSession(sessionName, theme, "", "Deacon", "health-check")
|
||||
|
||||
// Launch Claude directly (no shell respawn loop)
|
||||
// Restarts are handled by daemon via ensureDeaconRunning on each heartbeat
|
||||
// The startup hook handles context loading automatically
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
time.Sleep(constants.ShutdownNotifyDelay)
|
||||
|
||||
// Inject startup nudge for predecessor discovery via /resume
|
||||
_ = session.StartupNudge(t, DeaconSessionName, session.StartupNudgeConfig{
|
||||
_ = session.StartupNudge(t, sessionName, session.StartupNudgeConfig{
|
||||
Recipient: "deacon",
|
||||
Sender: "daemon",
|
||||
Topic: "patrol",
|
||||
@@ -355,7 +371,7 @@ func startDeaconSession(t *tmux.Tmux) error {
|
||||
// Send the propulsion nudge to trigger autonomous patrol execution.
|
||||
// Wait for beacon to be fully processed (needs to be separate prompt)
|
||||
time.Sleep(2 * time.Second)
|
||||
_ = t.NudgeSession(DeaconSessionName, session.PropulsionNudgeForRole("deacon", deaconDir)) // Non-fatal
|
||||
_ = t.NudgeSession(sessionName, session.PropulsionNudgeForRole("deacon", deaconDir)) // Non-fatal
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -363,8 +379,13 @@ func startDeaconSession(t *tmux.Tmux) error {
|
||||
func runDeaconStop(cmd *cobra.Command, args []string) error {
|
||||
t := tmux.NewTmux()
|
||||
|
||||
sessionName, err := getDeaconSessionName()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check if session exists
|
||||
running, err := t.HasSession(DeaconSessionName)
|
||||
running, err := t.HasSession(sessionName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("checking session: %w", err)
|
||||
}
|
||||
@@ -375,11 +396,11 @@ func runDeaconStop(cmd *cobra.Command, args []string) error {
|
||||
fmt.Println("Stopping Deacon session...")
|
||||
|
||||
// Try graceful shutdown first (best-effort interrupt)
|
||||
_ = t.SendKeysRaw(DeaconSessionName, "C-c")
|
||||
_ = t.SendKeysRaw(sessionName, "C-c")
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// Kill the session
|
||||
if err := t.KillSession(DeaconSessionName); err != nil {
|
||||
if err := t.KillSession(sessionName); err != nil {
|
||||
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 {
|
||||
t := tmux.NewTmux()
|
||||
|
||||
sessionName, err := getDeaconSessionName()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check if session exists
|
||||
running, err := t.HasSession(DeaconSessionName)
|
||||
running, err := t.HasSession(sessionName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("checking session: %w", err)
|
||||
}
|
||||
if !running {
|
||||
// Auto-start if not running
|
||||
fmt.Println("Deacon session not running, starting...")
|
||||
if err := startDeaconSession(t); err != nil {
|
||||
if err := startDeaconSession(t, sessionName); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// Session uses a respawn loop, so Claude restarts automatically if it exits
|
||||
|
||||
// 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 {
|
||||
t := tmux.NewTmux()
|
||||
|
||||
running, err := t.HasSession(DeaconSessionName)
|
||||
sessionName, err := getDeaconSessionName()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
running, err := t.HasSession(sessionName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("checking session: %w", err)
|
||||
}
|
||||
|
||||
if running {
|
||||
// Get session info for more details
|
||||
info, err := t.GetSessionInfo(DeaconSessionName)
|
||||
info, err := t.GetSessionInfo(sessionName)
|
||||
if err == nil {
|
||||
status := "detached"
|
||||
if info.Attached {
|
||||
@@ -448,7 +479,12 @@ func runDeaconStatus(cmd *cobra.Command, args []string) error {
|
||||
func runDeaconRestart(cmd *cobra.Command, args []string) error {
|
||||
t := tmux.NewTmux()
|
||||
|
||||
running, err := t.HasSession(DeaconSessionName)
|
||||
sessionName, err := getDeaconSessionName()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
running, err := t.HasSession(sessionName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("checking session: %w", err)
|
||||
}
|
||||
@@ -457,7 +493,7 @@ func runDeaconRestart(cmd *cobra.Command, args []string) error {
|
||||
|
||||
if running {
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -637,8 +673,14 @@ func runDeaconHealthCheck(cmd *cobra.Command, args []string) error {
|
||||
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)
|
||||
beadID, sessionName, err := agentAddressToIDs(agent)
|
||||
beadID, sessionName, err := agentAddressToIDs(agent, townName)
|
||||
if err != nil {
|
||||
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))
|
||||
}
|
||||
|
||||
// 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
|
||||
_, sessionName, err := agentAddressToIDs(agent)
|
||||
_, sessionName, err := agentAddressToIDs(agent, townName)
|
||||
if err != nil {
|
||||
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.
|
||||
// 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 {
|
||||
case "deacon":
|
||||
return "gt-deacon", DeaconSessionName, nil
|
||||
sessName := session.DeaconSessionName(townName)
|
||||
return sessName, sessName, nil
|
||||
case "mayor":
|
||||
return "gt-mayor", "gt-mayor", nil
|
||||
sessName := session.MayorSessionName(townName)
|
||||
return sessName, sessName, nil
|
||||
}
|
||||
|
||||
parts := strings.Split(address, "/")
|
||||
@@ -1176,7 +1227,11 @@ func sendMail(townRoot, to, subject, body string) {
|
||||
|
||||
// updateAgentBeadState updates an agent bead's state.
|
||||
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 {
|
||||
return
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user