fix(mail): handle crew/polecat ambiguity in notification session lookup (#914)

When sending mail notifications, the canonical address format (rig/name)
doesn't distinguish between crew workers (session: gt-rig-crew-name) and
polecats (session: gt-rig-name). This caused notifications to fail for
crew workers in other rigs.

Solution: Try both possible session IDs when the address is ambiguous,
using the first one that has an active session.

Supersedes PR #896 which only handled slash-to-dash conversion.

Fixes: gt-h5btjg
This commit is contained in:
aleiby
2026-01-24 21:48:16 -08:00
committed by GitHub
parent 63d60f1dcd
commit db60489d0f
2 changed files with 104 additions and 21 deletions

View File

@@ -991,47 +991,85 @@ func (r *Router) GetMailbox(address string) (*Mailbox, error) {
// notifyRecipient sends a notification to a recipient's tmux session.
// Uses NudgeSession to add the notification to the agent's conversation history.
// Supports mayor/, rig/polecat, and rig/refinery addresses.
// Supports mayor/, deacon/, rig/crew/name, rig/polecats/name, and rig/name addresses.
func (r *Router) notifyRecipient(msg *Message) error {
sessionID := addressToSessionID(msg.To)
if sessionID == "" {
sessionIDs := addressToSessionIDs(msg.To)
if len(sessionIDs) == 0 {
return nil // Unable to determine session ID
}
// Check if session exists
hasSession, err := r.tmux.HasSession(sessionID)
if err != nil || !hasSession {
return nil // No active session, skip notification
// Try each possible session ID until we find one that exists.
// This handles the ambiguity where canonical addresses (rig/name) don't
// distinguish between crew workers (gt-rig-crew-name) and polecats (gt-rig-name).
for _, sessionID := range sessionIDs {
hasSession, err := r.tmux.HasSession(sessionID)
if err != nil || !hasSession {
continue
}
// Send notification to the agent's conversation history
notification := fmt.Sprintf("📬 You have new mail from %s. Subject: %s. Run 'gt mail inbox' to read.", msg.From, msg.Subject)
return r.tmux.NudgeSession(sessionID, notification)
}
// Send notification to the agent's conversation history
notification := fmt.Sprintf("📬 You have new mail from %s. Subject: %s. Run 'gt mail inbox' to read.", msg.From, msg.Subject)
return r.tmux.NudgeSession(sessionID, notification)
return nil // No active session found
}
// addressToSessionID converts a mail address to a tmux session ID.
// Returns empty string if address format is not recognized.
func addressToSessionID(address string) string {
// addressToSessionIDs converts a mail address to possible tmux session IDs.
// Returns multiple candidates since the canonical address format (rig/name)
// doesn't distinguish between crew workers (gt-rig-crew-name) and polecats
// (gt-rig-name). The caller should try each and use the one that exists.
//
// This supersedes the approach in PR #896 which only handled slash-to-dash
// conversion but didn't address the crew/polecat ambiguity.
func addressToSessionIDs(address string) []string {
// Mayor address: "mayor/" or "mayor"
if strings.HasPrefix(address, "mayor") {
return session.MayorSessionName()
return []string{session.MayorSessionName()}
}
// Deacon address: "deacon/" or "deacon"
if strings.HasPrefix(address, "deacon") {
return session.DeaconSessionName()
return []string{session.DeaconSessionName()}
}
// Rig-based address: "rig/target"
// Rig-based address: "rig/target" or "rig/crew/name" or "rig/polecats/name"
parts := strings.SplitN(address, "/", 2)
if len(parts) != 2 || parts[1] == "" {
return ""
return nil
}
rig := parts[0]
target := parts[1]
// Polecat: gt-rig-polecat
// Refinery: gt-rig-refinery (if refinery has its own session)
return fmt.Sprintf("gt-%s-%s", rig, target)
// If target already has crew/ or polecats/ prefix, use it directly
// e.g., "gastown/crew/holden" → "gt-gastown-crew-holden"
if strings.HasPrefix(target, "crew/") || strings.HasPrefix(target, "polecats/") {
return []string{fmt.Sprintf("gt-%s-%s", rig, strings.ReplaceAll(target, "/", "-"))}
}
// Special cases that don't need crew variant
if target == "witness" || target == "refinery" {
return []string{fmt.Sprintf("gt-%s-%s", rig, target)}
}
// For normalized addresses like "gastown/holden", try both:
// 1. Crew format: gt-gastown-crew-holden
// 2. Polecat format: gt-gastown-holden
// Return crew first since crew workers are more commonly missed.
return []string{
session.CrewSessionName(rig, target), // gt-rig-crew-name
session.PolecatSessionName(rig, target), // gt-rig-name
}
}
// addressToSessionID converts a mail address to a tmux session ID.
// Returns empty string if address format is not recognized.
// Deprecated: Use addressToSessionIDs for proper crew/polecat handling.
func addressToSessionID(address string) string {
ids := addressToSessionIDs(address)
if len(ids) == 0 {
return ""
}
return ids[0]
}

View File

@@ -87,7 +87,52 @@ func TestIsTownLevelAddress(t *testing.T) {
}
}
func TestAddressToSessionIDs(t *testing.T) {
tests := []struct {
address string
want []string
}{
// Town-level addresses - single session
{"mayor", []string{"hq-mayor"}},
{"mayor/", []string{"hq-mayor"}},
{"deacon", []string{"hq-deacon"}},
// Rig singletons - single session (no crew/polecat ambiguity)
{"gastown/refinery", []string{"gt-gastown-refinery"}},
{"beads/witness", []string{"gt-beads-witness"}},
// Ambiguous addresses - try both crew and polecat variants
{"gastown/Toast", []string{"gt-gastown-crew-Toast", "gt-gastown-Toast"}},
{"beads/ruby", []string{"gt-beads-crew-ruby", "gt-beads-ruby"}},
// Explicit crew/polecat - single session
{"gastown/crew/max", []string{"gt-gastown-crew-max"}},
{"gastown/polecats/nux", []string{"gt-gastown-polecats-nux"}},
// Invalid addresses - empty result
{"gastown/", nil}, // Empty target
{"gastown", nil}, // No slash
{"", nil}, // Empty address
}
for _, tt := range tests {
t.Run(tt.address, func(t *testing.T) {
got := addressToSessionIDs(tt.address)
if len(got) != len(tt.want) {
t.Errorf("addressToSessionIDs(%q) = %v, want %v", tt.address, got, tt.want)
return
}
for i, v := range got {
if v != tt.want[i] {
t.Errorf("addressToSessionIDs(%q)[%d] = %q, want %q", tt.address, i, v, tt.want[i])
}
}
})
}
}
func TestAddressToSessionID(t *testing.T) {
// Deprecated wrapper - returns first candidate from addressToSessionIDs
tests := []struct {
address string
want string
@@ -96,7 +141,7 @@ func TestAddressToSessionID(t *testing.T) {
{"mayor/", "hq-mayor"},
{"deacon", "hq-deacon"},
{"gastown/refinery", "gt-gastown-refinery"},
{"gastown/Toast", "gt-gastown-Toast"},
{"gastown/Toast", "gt-gastown-crew-Toast"}, // First candidate is crew
{"beads/witness", "gt-beads-witness"},
{"gastown/", ""}, // Empty target
{"gastown", ""}, // No slash