feat: use hq- prefix for Mayor and Deacon session names

Town-level services (Mayor, Deacon) now use hq- prefix instead of gt-:
- hq-mayor (was gt-mayor)
- hq-deacon (was gt-deacon)

This distinguishes town-level sessions from rig-level sessions which
continue to use gt- prefix (gt-gastown-witness, gt-gastown-crew-max, etc).

Changes:
- session.MayorSessionName() returns "hq-mayor"
- session.DeaconSessionName() returns "hq-deacon"
- ParseSessionName() handles both hq- and gt- prefixes
- categorizeSession() handles both prefixes
- categorizeSessions() accepts both prefixes
- Updated all tests and documentation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
gastown/crew/jack
2026-01-05 00:42:10 -08:00
committed by Steve Yegge
parent a459cd9fd6
commit 6b8c897e37
16 changed files with 92 additions and 74 deletions

View File

@@ -66,10 +66,10 @@ The daemon could directly monitor agents without AI, but:
| Agent | Session Name | Location | Lifecycle |
|-------|--------------|----------|-----------|
| Daemon | (Go process) | `~/gt/daemon/` | Persistent, auto-restart |
| Boot | `gt-deacon-boot` | `~/gt/deacon/dogs/boot/` | Ephemeral, fresh each tick |
| Deacon | `gt-deacon` | `~/gt/deacon/` | Long-running, handoff loop |
| Boot | `gt-boot` | `~/gt/deacon/dogs/boot/` | Ephemeral, fresh each tick |
| Deacon | `hq-deacon` | `~/gt/deacon/` | Long-running, handoff loop |
**Critical**: Boot runs in `gt-deacon-boot`, NOT `gt-deacon`. This prevents Boot
**Critical**: Boot runs in `gt-boot`, NOT `hq-deacon`. This prevents Boot
from conflicting with a running Deacon session.
## Heartbeat Mechanics
@@ -227,15 +227,15 @@ gt deacon health-check
### Boot Spawns in Wrong Session
**Symptom**: Boot runs in `gt-deacon` instead of `gt-deacon-boot`
**Symptom**: Boot runs in `hq-deacon` instead of `gt-boot`
**Cause**: Session name confusion in spawn code
**Fix**: Ensure `gt boot triage` specifies `--session=gt-deacon-boot`
**Fix**: Ensure `gt boot triage` specifies `--session=gt-boot`
### Zombie Sessions Block Restart
**Symptom**: tmux session exists but Claude is dead
**Cause**: Daemon checks session existence, not process health
**Fix**: Kill zombie sessions before recreating: `gt session kill gt-deacon`
**Fix**: Kill zombie sessions before recreating: `gt session kill hq-deacon`
### Status Shows Wrong State
@@ -250,15 +250,15 @@ The issue [gt-1847v] considered three options:
### Option A: Keep Boot/Deacon Separation (CHOSEN)
- Boot is ephemeral, spawns fresh each heartbeat
- Boot runs in `gt-deacon-boot`, exits after triage
- Deacon runs in `gt-deacon`, continuous patrol
- Boot runs in `gt-boot`, exits after triage
- Deacon runs in `hq-deacon`, continuous patrol
- Clear session boundaries, clear lifecycle
**Verdict**: This is the correct design. The implementation needs fixing, not the architecture.
### Option B: Merge Boot into Deacon (Rejected)
- Single `gt-deacon` session handles everything
- Single `hq-deacon` session handles everything
- Deacon checks "should I be awake?" internally
**Why rejected**:

View File

@@ -509,7 +509,7 @@ func FormatSynthesisFields(fields *SynthesisFields) string {
type RoleConfig struct {
// SessionPattern defines how to derive tmux session name.
// Supports placeholders: {rig}, {name}, {role}
// Examples: "gt-mayor", "gt-{rig}-{role}", "gt-{rig}-{name}"
// Examples: "hq-mayor", "hq-deacon", "gt-{rig}-{role}", "gt-{rig}-{name}"
SessionPattern string
// WorkDirPattern defines the working directory relative to town root.

View File

@@ -16,9 +16,9 @@ import (
)
// SessionName is the tmux session name for Boot.
// Note: We use "gt-boot" instead of "gt-deacon-boot" to avoid tmux prefix
// matching collisions. Tmux matches session names by prefix, so "gt-deacon-boot"
// would match when checking for "gt-deacon", causing HasSession("gt-deacon")
// Note: We use "gt-boot" instead of "hq-deacon-boot" to avoid tmux prefix
// matching collisions. Tmux matches session names by prefix, so "hq-deacon-boot"
// would match when checking for "hq-deacon", causing HasSession("hq-deacon")
// to return true when only Boot is running.
const SessionName = "gt-boot"

View File

@@ -127,24 +127,29 @@ func init() {
// categorizeSession determines the agent type from a session name.
func categorizeSession(name string) *AgentSession {
// Must start with gt- prefix
session := &AgentSession{Name: name}
// Town-level agents use hq- prefix: hq-mayor, hq-deacon
if strings.HasPrefix(name, "hq-") {
suffix := strings.TrimPrefix(name, "hq-")
if suffix == "mayor" {
session.Type = AgentMayor
return session
}
if suffix == "deacon" {
session.Type = AgentDeacon
return session
}
return nil // Unknown hq- session
}
// Rig-level agents use gt- prefix
if !strings.HasPrefix(name, "gt-") {
return nil
}
session := &AgentSession{Name: name}
suffix := strings.TrimPrefix(name, "gt-")
// Town-level agents: gt-mayor, gt-deacon (simple format, one per machine)
if suffix == "mayor" {
session.Type = AgentMayor
return session
}
if suffix == "deacon" {
session.Type = AgentDeacon
return session
}
// Witness sessions: legacy format gt-witness-<rig> (fallback)
if strings.HasPrefix(suffix, "witness-") {
session.Type = AgentWitness

View File

@@ -9,9 +9,9 @@ func TestAddressToAgentBeadID(t *testing.T) {
address string
expected string
}{
// Mayor and deacon use simple session names (no town qualifier)
{"mayor", "gt-mayor"},
{"deacon", "gt-deacon"},
// Mayor and deacon use hq- prefix (town-level)
{"mayor", "hq-mayor"},
{"deacon", "hq-deacon"},
{"gastown/witness", "gt-gastown-witness"},
{"gastown/refinery", "gt-gastown-refinery"},
{"gastown/alpha", "gt-gastown-polecat-alpha"},

View File

@@ -5,10 +5,10 @@ import (
)
func TestResolveNudgePattern(t *testing.T) {
// Create test agent sessions (no Town field for mayor/deacon anymore)
// Create test agent sessions (mayor/deacon use hq- prefix)
agents := []*AgentSession{
{Name: "gt-mayor", Type: AgentMayor},
{Name: "gt-deacon", Type: AgentDeacon},
{Name: "hq-mayor", Type: AgentMayor},
{Name: "hq-deacon", Type: AgentDeacon},
{Name: "gt-gastown-witness", Type: AgentWitness, Rig: "gastown"},
{Name: "gt-gastown-refinery", Type: AgentRefinery, Rig: "gastown"},
{Name: "gt-gastown-crew-max", Type: AgentCrew, Rig: "gastown", AgentName: "max"},
@@ -27,12 +27,12 @@ func TestResolveNudgePattern(t *testing.T) {
{
name: "mayor special case",
pattern: "mayor",
expected: []string{"gt-mayor"},
expected: []string{"hq-mayor"},
},
{
name: "deacon special case",
pattern: "deacon",
expected: []string{"gt-deacon"},
expected: []string{"hq-deacon"},
},
{
name: "specific witness",

View File

@@ -430,7 +430,8 @@ func runShutdown(cmd *cobra.Command, args []string) error {
// 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 {
if !strings.HasPrefix(sess, "gt-") {
// Gas Town sessions use gt- (rig-level) or hq- (town-level) prefix
if !strings.HasPrefix(sess, "gt-") && !strings.HasPrefix(sess, "hq-") {
continue // Not a Gas Town session
}

View File

@@ -30,9 +30,9 @@ func TestCategorizeSessionRig(t *testing.T) {
// Edge cases
{"gt-a-b", "a"}, // minimum valid
// Town-level agents (no rig)
{"gt-mayor", ""},
{"gt-deacon", ""},
// Town-level agents (no rig, use hq- prefix)
{"hq-mayor", ""},
{"hq-deacon", ""},
}
for _, tt := range tests {
@@ -67,9 +67,9 @@ func TestCategorizeSessionType(t *testing.T) {
{"gt-gastown-crew-max", AgentCrew},
{"gt-myrig-crew-user", AgentCrew},
// Town-level agents
{"gt-mayor", AgentMayor},
{"gt-deacon", AgentDeacon},
// Town-level agents (hq- prefix)
{"hq-mayor", AgentMayor},
{"hq-deacon", AgentDeacon},
}
for _, tt := range tests {

View File

@@ -89,11 +89,15 @@ const (
)
// Tmux session names.
// Mayor and Deacon use simple session names: gt-mayor, gt-deacon (one per machine).
// Mayor and Deacon use hq- prefix: hq-mayor, hq-deacon (town-level, one per machine).
// Rig-level services use gt- prefix: gt-<rig>-witness, gt-<rig>-refinery, etc.
// Use session.MayorSessionName() and session.DeaconSessionName().
const (
// SessionPrefix is the prefix for all Gas Town tmux sessions.
// SessionPrefix is the prefix for rig-level Gas Town tmux sessions.
SessionPrefix = "gt-"
// HQSessionPrefix is the prefix for town-level services (Mayor, Deacon).
HQSessionPrefix = "hq-"
)
// Agent role names.

View File

@@ -184,10 +184,10 @@ func TestIdentityToSession_Mayor(t *testing.T) {
d, cleanup := testDaemonWithTown(t, "ai")
defer cleanup()
// Mayor session name is now fixed (one per machine, no town qualifier)
// Mayor session name is now fixed (one per machine, uses hq- prefix)
result := d.identityToSession("mayor")
if result != "gt-mayor" {
t.Errorf("identityToSession('mayor') = %q, expected 'gt-mayor'", result)
if result != "hq-mayor" {
t.Errorf("identityToSession('mayor') = %q, expected 'hq-mayor'", result)
}
}

View File

@@ -91,9 +91,9 @@ func TestAddressToSessionID(t *testing.T) {
address string
want string
}{
{"mayor", "gt-mayor"},
{"mayor/", "gt-mayor"},
{"deacon", "gt-deacon"},
{"mayor", "hq-mayor"},
{"mayor/", "hq-mayor"},
{"deacon", "hq-deacon"},
{"gastown/refinery", "gt-gastown-refinery"},
{"gastown/Toast", "gt-gastown-Toast"},
{"beads/witness", "gt-beads-witness"},

View File

@@ -28,8 +28,8 @@ type AgentIdentity struct {
// ParseSessionName parses a tmux session name into an AgentIdentity.
//
// Session name formats:
// - gt-mayor → Role: mayor (one per machine)
// - gt-deacon → Role: deacon (one per machine)
// - hq-mayor → Role: mayor (town-level, one per machine)
// - hq-deacon → Role: deacon (town-level, one per machine)
// - gt-<rig>-witness → Role: witness, Rig: <rig>
// - gt-<rig>-refinery → Role: refinery, Rig: <rig>
// - gt-<rig>-crew-<name> → Role: crew, Rig: <rig>, Name: <name>
@@ -39,8 +39,21 @@ type AgentIdentity struct {
// is assumed to be the polecat name. This works for simple rig names but may
// be ambiguous for rig names containing hyphens.
func ParseSessionName(session string) (*AgentIdentity, error) {
// Check for town-level roles (hq- prefix)
if strings.HasPrefix(session, HQPrefix) {
suffix := strings.TrimPrefix(session, HQPrefix)
if suffix == "mayor" {
return &AgentIdentity{Role: RoleMayor}, nil
}
if suffix == "deacon" {
return &AgentIdentity{Role: RoleDeacon}, nil
}
return nil, fmt.Errorf("invalid session name %q: unknown hq- role", session)
}
// Rig-level roles use gt- prefix
if !strings.HasPrefix(session, Prefix) {
return nil, fmt.Errorf("invalid session name %q: missing %q prefix", session, Prefix)
return nil, fmt.Errorf("invalid session name %q: missing %q or %q prefix", session, HQPrefix, Prefix)
}
suffix := strings.TrimPrefix(session, Prefix)
@@ -48,14 +61,6 @@ func ParseSessionName(session string) (*AgentIdentity, error) {
return nil, fmt.Errorf("invalid session name %q: empty after prefix", session)
}
// Check for simple town-level roles (no rig qualifier)
if suffix == "mayor" {
return &AgentIdentity{Role: RoleMayor}, nil
}
if suffix == "deacon" {
return &AgentIdentity{Role: RoleDeacon}, nil
}
// Parse into parts for rig-level roles
parts := strings.Split(suffix, "-")
if len(parts) < 2 {

View File

@@ -13,15 +13,15 @@ func TestParseSessionName(t *testing.T) {
wantName string
wantErr bool
}{
// Town-level roles (simple gt-mayor, gt-deacon)
// Town-level roles (hq-mayor, hq-deacon)
{
name: "mayor",
session: "gt-mayor",
session: "hq-mayor",
wantRole: RoleMayor,
},
{
name: "deacon",
session: "gt-deacon",
session: "hq-deacon",
wantRole: RoleDeacon,
},
@@ -142,12 +142,12 @@ func TestAgentIdentity_SessionName(t *testing.T) {
{
name: "mayor",
identity: AgentIdentity{Role: RoleMayor},
want: "gt-mayor",
want: "hq-mayor",
},
{
name: "deacon",
identity: AgentIdentity{Role: RoleDeacon},
want: "gt-deacon",
want: "hq-deacon",
},
{
name: "witness",
@@ -230,8 +230,8 @@ func TestAgentIdentity_Address(t *testing.T) {
func TestParseSessionName_RoundTrip(t *testing.T) {
// Test that parsing then reconstructing gives the same result
sessions := []string{
"gt-mayor",
"gt-deacon",
"hq-mayor",
"hq-deacon",
"gt-gastown-witness",
"gt-foo-bar-refinery",
"gt-gastown-crew-max",

View File

@@ -8,19 +8,22 @@ import (
"strings"
)
// Prefix is the common prefix for all Gas Town tmux session names.
// Prefix is the common prefix for rig-level Gas Town tmux sessions.
const Prefix = "gt-"
// HQPrefix is the prefix for town-level services (Mayor, Deacon).
const HQPrefix = "hq-"
// MayorSessionName returns the session name for the Mayor agent.
// One mayor per machine - multi-town requires containers/VMs for isolation.
func MayorSessionName() string {
return Prefix + "mayor"
return HQPrefix + "mayor"
}
// DeaconSessionName returns the session name for the Deacon agent.
// One deacon per machine - multi-town requires containers/VMs for isolation.
func DeaconSessionName() string {
return Prefix + "deacon"
return HQPrefix + "deacon"
}
// WitnessSessionName returns the session name for a rig's Witness agent.

View File

@@ -8,8 +8,8 @@ import (
)
func TestMayorSessionName(t *testing.T) {
// Mayor session name is now fixed (one per machine)
want := "gt-mayor"
// Mayor session name is now fixed (one per machine), uses HQ prefix
want := "hq-mayor"
got := MayorSessionName()
if got != want {
t.Errorf("MayorSessionName() = %q, want %q", got, want)
@@ -17,8 +17,8 @@ func TestMayorSessionName(t *testing.T) {
}
func TestDeaconSessionName(t *testing.T) {
// Deacon session name is now fixed (one per machine)
want := "gt-deacon"
// Deacon session name is now fixed (one per machine), uses HQ prefix
want := "hq-deacon"
got := DeaconSessionName()
if got != want {
t.Errorf("DeaconSessionName() = %q, want %q", got, want)

View File

@@ -12,7 +12,7 @@ import (
// TownSession represents a town-level tmux session.
type TownSession struct {
Name string // Display name (e.g., "Mayor")
SessionID string // Tmux session ID (e.g., "gt-mayor")
SessionID string // Tmux session ID (e.g., "hq-mayor")
}
// TownSessions returns the list of town-level sessions in shutdown order.