diff --git a/docs/watchdog-chain.md b/docs/watchdog-chain.md index 0480a9e9..70604723 100644 --- a/docs/watchdog-chain.md +++ b/docs/watchdog-chain.md @@ -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**: diff --git a/internal/beads/fields.go b/internal/beads/fields.go index 0b39f915..220e2e25 100644 --- a/internal/beads/fields.go +++ b/internal/beads/fields.go @@ -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. diff --git a/internal/boot/boot.go b/internal/boot/boot.go index 2b114129..52f22c84 100644 --- a/internal/boot/boot.go +++ b/internal/boot/boot.go @@ -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" diff --git a/internal/cmd/agents.go b/internal/cmd/agents.go index c996d871..686f0e07 100644 --- a/internal/cmd/agents.go +++ b/internal/cmd/agents.go @@ -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- (fallback) if strings.HasPrefix(suffix, "witness-") { session.Type = AgentWitness diff --git a/internal/cmd/dnd_test.go b/internal/cmd/dnd_test.go index d9d9ee8a..d5d0c489 100644 --- a/internal/cmd/dnd_test.go +++ b/internal/cmd/dnd_test.go @@ -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"}, diff --git a/internal/cmd/nudge_test.go b/internal/cmd/nudge_test.go index 176b8f64..2b865912 100644 --- a/internal/cmd/nudge_test.go +++ b/internal/cmd/nudge_test.go @@ -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", diff --git a/internal/cmd/start.go b/internal/cmd/start.go index aaaf6b6c..bb52fe26 100644 --- a/internal/cmd/start.go +++ b/internal/cmd/start.go @@ -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 } diff --git a/internal/cmd/statusline_test.go b/internal/cmd/statusline_test.go index 5f31d337..f0fdb0ad 100644 --- a/internal/cmd/statusline_test.go +++ b/internal/cmd/statusline_test.go @@ -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 { diff --git a/internal/constants/constants.go b/internal/constants/constants.go index ff2897d7..9bd0b15b 100644 --- a/internal/constants/constants.go +++ b/internal/constants/constants.go @@ -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--witness, gt--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. diff --git a/internal/daemon/lifecycle_test.go b/internal/daemon/lifecycle_test.go index 29c62343..34b460ab 100644 --- a/internal/daemon/lifecycle_test.go +++ b/internal/daemon/lifecycle_test.go @@ -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) } } diff --git a/internal/mail/router_test.go b/internal/mail/router_test.go index bd4cf09b..0b53e387 100644 --- a/internal/mail/router_test.go +++ b/internal/mail/router_test.go @@ -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"}, diff --git a/internal/session/identity.go b/internal/session/identity.go index 1ac72b5d..f18fc5cf 100644 --- a/internal/session/identity.go +++ b/internal/session/identity.go @@ -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--witness → Role: witness, Rig: // - gt--refinery → Role: refinery, Rig: // - gt--crew- → Role: crew, Rig: , 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 { diff --git a/internal/session/identity_test.go b/internal/session/identity_test.go index 3fb02de1..c97c8c7d 100644 --- a/internal/session/identity_test.go +++ b/internal/session/identity_test.go @@ -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", diff --git a/internal/session/names.go b/internal/session/names.go index 919a6207..a36a077c 100644 --- a/internal/session/names.go +++ b/internal/session/names.go @@ -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. diff --git a/internal/session/names_test.go b/internal/session/names_test.go index a4949600..ad842328 100644 --- a/internal/session/names_test.go +++ b/internal/session/names_test.go @@ -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) diff --git a/internal/session/town.go b/internal/session/town.go index 05fdd44d..20c4ec52 100644 --- a/internal/session/town.go +++ b/internal/session/town.go @@ -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.