Files
gastown/internal/cmd/escalate_impl.go
Julian Knutsen 3caf32f9f7 fix(config): don't export empty GT_ROOT/BEADS_DIR in AgentEnv (#385)
* fix(config): don't export empty GT_ROOT/BEADS_DIR in AgentEnv

Fix polecats not having GT_ROOT environment variable set. The symptom was
polecat sessions showing GT_ROOT="" instead of the expected town root.

Root cause: AgentEnvSimple doesn't set TownRoot, but AgentEnv was always
setting env["GT_ROOT"] = cfg.TownRoot even when empty. This empty value
in export commands would override the tmux session environment.

Changes:
- Only set GT_ROOT and BEADS_DIR in env map if non-empty
- Refactor daemon.go to use AgentEnv with full AgentEnvConfig instead
  of AgentEnvSimple + manual additions
- Update test to verify keys are absent rather than empty

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(lint): silence unparam for unused executeExternalActions args

The external action params (beadID, severity, description) are reserved
for future email/SMS/slack implementations but currently unused.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: julianknutsen <julianknutsen@users.noreply.github>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: max <steve.yegge@gmail.com>
2026-01-12 02:45:03 -08:00

658 lines
19 KiB
Go
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package cmd
import (
"encoding/json"
"fmt"
"os"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/beads"
"github.com/steveyegge/gastown/internal/config"
"github.com/steveyegge/gastown/internal/events"
"github.com/steveyegge/gastown/internal/mail"
"github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/workspace"
)
func runEscalate(cmd *cobra.Command, args []string) error {
// Require at least a description when creating an escalation
if len(args) == 0 {
return cmd.Help()
}
description := strings.Join(args, " ")
// Validate severity
severity := strings.ToLower(escalateSeverity)
if !config.IsValidSeverity(severity) {
return fmt.Errorf("invalid severity '%s': must be critical, high, medium, or low", escalateSeverity)
}
// Find workspace
townRoot, err := workspace.FindFromCwdOrError()
if err != nil {
return fmt.Errorf("not in a Gas Town workspace: %w", err)
}
// Load escalation config
escalationConfig, err := config.LoadOrCreateEscalationConfig(config.EscalationConfigPath(townRoot))
if err != nil {
return fmt.Errorf("loading escalation config: %w", err)
}
// Detect agent identity
agentID := detectSender()
if agentID == "" {
agentID = "unknown"
}
// Dry run mode
if escalateDryRun {
actions := escalationConfig.GetRouteForSeverity(severity)
targets := extractMailTargetsFromActions(actions)
fmt.Printf("Would create escalation:\n")
fmt.Printf(" Severity: %s\n", severity)
fmt.Printf(" Description: %s\n", description)
if escalateReason != "" {
fmt.Printf(" Reason: %s\n", escalateReason)
}
if escalateSource != "" {
fmt.Printf(" Source: %s\n", escalateSource)
}
fmt.Printf(" Actions: %s\n", strings.Join(actions, ", "))
fmt.Printf(" Mail targets: %s\n", strings.Join(targets, ", "))
return nil
}
// Create escalation bead
bd := beads.New(beads.ResolveBeadsDir(townRoot))
fields := &beads.EscalationFields{
Severity: severity,
Reason: escalateReason,
Source: escalateSource,
EscalatedBy: agentID,
EscalatedAt: time.Now().Format(time.RFC3339),
RelatedBead: escalateRelatedBead,
}
issue, err := bd.CreateEscalationBead(description, fields)
if err != nil {
return fmt.Errorf("creating escalation bead: %w", err)
}
// Get routing actions for this severity
actions := escalationConfig.GetRouteForSeverity(severity)
targets := extractMailTargetsFromActions(actions)
// Send mail to each target (actions with "mail:" prefix)
router := mail.NewRouter(townRoot)
for _, target := range targets {
msg := &mail.Message{
From: agentID,
To: target,
Subject: fmt.Sprintf("[%s] %s", strings.ToUpper(severity), description),
Body: formatEscalationMailBody(issue.ID, severity, escalateReason, agentID, escalateRelatedBead),
Type: mail.TypeTask,
}
// Set priority based on severity
switch severity {
case config.SeverityCritical:
msg.Priority = mail.PriorityUrgent
case config.SeverityHigh:
msg.Priority = mail.PriorityHigh
case config.SeverityMedium:
msg.Priority = mail.PriorityNormal
default:
msg.Priority = mail.PriorityLow
}
if err := router.Send(msg); err != nil {
style.PrintWarning("failed to send to %s: %v", target, err)
}
}
// Process external notification actions (email:, sms:, slack)
executeExternalActions(actions, escalationConfig, issue.ID, severity, description)
// Log to activity feed
payload := events.EscalationPayload(issue.ID, agentID, strings.Join(targets, ","), description)
payload["severity"] = severity
payload["actions"] = strings.Join(actions, ",")
if escalateSource != "" {
payload["source"] = escalateSource
}
_ = events.LogFeed(events.TypeEscalationSent, agentID, payload)
// Output
if escalateJSON {
result := map[string]interface{}{
"id": issue.ID,
"severity": severity,
"actions": actions,
"targets": targets,
}
if escalateSource != "" {
result["source"] = escalateSource
}
out, _ := json.MarshalIndent(result, "", " ")
fmt.Println(string(out))
} else {
emoji := severityEmoji(severity)
fmt.Printf("%s Escalation created: %s\n", emoji, issue.ID)
fmt.Printf(" Severity: %s\n", severity)
if escalateSource != "" {
fmt.Printf(" Source: %s\n", escalateSource)
}
fmt.Printf(" Routed to: %s\n", strings.Join(targets, ", "))
}
return nil
}
func runEscalateList(cmd *cobra.Command, args []string) error {
townRoot, err := workspace.FindFromCwdOrError()
if err != nil {
return fmt.Errorf("not in a Gas Town workspace: %w", err)
}
bd := beads.New(beads.ResolveBeadsDir(townRoot))
var issues []*beads.Issue
if escalateListAll {
// List all (open and closed)
out, err := bd.Run("list", "--label=gt:escalation", "--status=all", "--json")
if err != nil {
return fmt.Errorf("listing escalations: %w", err)
}
if err := json.Unmarshal(out, &issues); err != nil {
return fmt.Errorf("parsing escalations: %w", err)
}
} else {
issues, err = bd.ListEscalations()
if err != nil {
return fmt.Errorf("listing escalations: %w", err)
}
}
if escalateListJSON {
out, _ := json.MarshalIndent(issues, "", " ")
fmt.Println(string(out))
return nil
}
if len(issues) == 0 {
fmt.Println("No escalations found")
return nil
}
fmt.Printf("Escalations (%d):\n\n", len(issues))
for _, issue := range issues {
fields := beads.ParseEscalationFields(issue.Description)
emoji := severityEmoji(fields.Severity)
status := issue.Status
if beads.HasLabel(issue, "acked") {
status = "acked"
}
fmt.Printf(" %s %s [%s] %s\n", emoji, issue.ID, status, issue.Title)
fmt.Printf(" Severity: %s | From: %s | %s\n",
fields.Severity, fields.EscalatedBy, formatRelativeTime(issue.CreatedAt))
if fields.AckedBy != "" {
fmt.Printf(" Acked by: %s\n", fields.AckedBy)
}
fmt.Println()
}
return nil
}
func runEscalateAck(cmd *cobra.Command, args []string) error {
escalationID := args[0]
townRoot, err := workspace.FindFromCwdOrError()
if err != nil {
return fmt.Errorf("not in a Gas Town workspace: %w", err)
}
// Detect who is acknowledging
ackedBy := detectSender()
if ackedBy == "" {
ackedBy = "unknown"
}
bd := beads.New(beads.ResolveBeadsDir(townRoot))
if err := bd.AckEscalation(escalationID, ackedBy); err != nil {
return fmt.Errorf("acknowledging escalation: %w", err)
}
// Log to activity feed
_ = events.LogFeed(events.TypeEscalationAcked, ackedBy, map[string]interface{}{
"escalation_id": escalationID,
"acked_by": ackedBy,
})
fmt.Printf("%s Escalation acknowledged: %s\n", style.Bold.Render("✓"), escalationID)
return nil
}
func runEscalateClose(cmd *cobra.Command, args []string) error {
escalationID := args[0]
townRoot, err := workspace.FindFromCwdOrError()
if err != nil {
return fmt.Errorf("not in a Gas Town workspace: %w", err)
}
// Detect who is closing
closedBy := detectSender()
if closedBy == "" {
closedBy = "unknown"
}
bd := beads.New(beads.ResolveBeadsDir(townRoot))
if err := bd.CloseEscalation(escalationID, closedBy, escalateCloseReason); err != nil {
return fmt.Errorf("closing escalation: %w", err)
}
// Log to activity feed
_ = events.LogFeed(events.TypeEscalationClosed, closedBy, map[string]interface{}{
"escalation_id": escalationID,
"closed_by": closedBy,
"reason": escalateCloseReason,
})
fmt.Printf("%s Escalation closed: %s\n", style.Bold.Render("✓"), escalationID)
fmt.Printf(" Reason: %s\n", escalateCloseReason)
return nil
}
func runEscalateStale(cmd *cobra.Command, args []string) error {
townRoot, err := workspace.FindFromCwdOrError()
if err != nil {
return fmt.Errorf("not in a Gas Town workspace: %w", err)
}
// Load escalation config for threshold and max reescalations
escalationConfig, err := config.LoadOrCreateEscalationConfig(config.EscalationConfigPath(townRoot))
if err != nil {
return fmt.Errorf("loading escalation config: %w", err)
}
threshold := escalationConfig.GetStaleThreshold()
maxReescalations := escalationConfig.GetMaxReescalations()
bd := beads.New(beads.ResolveBeadsDir(townRoot))
stale, err := bd.ListStaleEscalations(threshold)
if err != nil {
return fmt.Errorf("listing stale escalations: %w", err)
}
if len(stale) == 0 {
if !escalateStaleJSON {
fmt.Printf("No stale escalations (threshold: %s)\n", threshold)
} else {
fmt.Println("[]")
}
return nil
}
// Detect who is reescalating
reescalatedBy := detectSender()
if reescalatedBy == "" {
reescalatedBy = "system"
}
// Dry run mode - just show what would happen
if escalateDryRun {
fmt.Printf("Would re-escalate %d stale escalations (threshold: %s):\n\n", len(stale), threshold)
for _, issue := range stale {
fields := beads.ParseEscalationFields(issue.Description)
newSeverity := getNextSeverity(fields.Severity)
willSkip := maxReescalations > 0 && fields.ReescalationCount >= maxReescalations
if fields.Severity == "critical" {
willSkip = true
}
emoji := severityEmoji(fields.Severity)
if willSkip {
fmt.Printf(" %s %s [SKIP] %s\n", emoji, issue.ID, issue.Title)
if fields.Severity == "critical" {
fmt.Printf(" Already at critical severity\n")
} else {
fmt.Printf(" Already at max reescalations (%d)\n", maxReescalations)
}
} else {
fmt.Printf(" %s %s %s\n", emoji, issue.ID, issue.Title)
fmt.Printf(" %s → %s (reescalation %d/%d)\n",
fields.Severity, newSeverity, fields.ReescalationCount+1, maxReescalations)
}
fmt.Println()
}
return nil
}
// Perform re-escalation
var results []*beads.ReescalationResult
router := mail.NewRouter(townRoot)
for _, issue := range stale {
result, err := bd.ReescalateEscalation(issue.ID, reescalatedBy, maxReescalations)
if err != nil {
style.PrintWarning("failed to reescalate %s: %v", issue.ID, err)
continue
}
results = append(results, result)
// If not skipped, re-route to new severity targets
if !result.Skipped {
actions := escalationConfig.GetRouteForSeverity(result.NewSeverity)
targets := extractMailTargetsFromActions(actions)
// Send mail to each target about the reescalation
for _, target := range targets {
msg := &mail.Message{
From: reescalatedBy,
To: target,
Subject: fmt.Sprintf("[%s→%s] Re-escalated: %s", strings.ToUpper(result.OldSeverity), strings.ToUpper(result.NewSeverity), result.Title),
Body: formatReescalationMailBody(result, reescalatedBy),
Type: mail.TypeTask,
}
// Set priority based on new severity
switch result.NewSeverity {
case config.SeverityCritical:
msg.Priority = mail.PriorityUrgent
case config.SeverityHigh:
msg.Priority = mail.PriorityHigh
case config.SeverityMedium:
msg.Priority = mail.PriorityNormal
default:
msg.Priority = mail.PriorityLow
}
if err := router.Send(msg); err != nil {
style.PrintWarning("failed to send reescalation to %s: %v", target, err)
}
}
// Log to activity feed
_ = events.LogFeed(events.TypeEscalationSent, reescalatedBy, map[string]interface{}{
"escalation_id": result.ID,
"reescalated": true,
"old_severity": result.OldSeverity,
"new_severity": result.NewSeverity,
"reescalation_num": result.ReescalationNum,
"targets": strings.Join(targets, ","),
})
}
}
// Output results
if escalateStaleJSON {
out, _ := json.MarshalIndent(results, "", " ")
fmt.Println(string(out))
return nil
}
reescalated := 0
skipped := 0
for _, r := range results {
if r.Skipped {
skipped++
} else {
reescalated++
}
}
if reescalated == 0 && skipped > 0 {
fmt.Printf("No escalations re-escalated (%d at max level)\n", skipped)
return nil
}
fmt.Printf("🔄 Re-escalated %d stale escalations:\n\n", reescalated)
for _, result := range results {
if result.Skipped {
continue
}
emoji := severityEmoji(result.NewSeverity)
fmt.Printf(" %s %s: %s → %s (reescalation %d)\n",
emoji, result.ID, result.OldSeverity, result.NewSeverity, result.ReescalationNum)
}
if skipped > 0 {
fmt.Printf("\n (%d skipped - at max level)\n", skipped)
}
return nil
}
func getNextSeverity(severity string) string {
switch severity {
case "low":
return "medium"
case "medium":
return "high"
case "high":
return "critical"
default:
return "critical"
}
}
func formatReescalationMailBody(result *beads.ReescalationResult, reescalatedBy string) string {
var lines []string
lines = append(lines, fmt.Sprintf("Escalation ID: %s", result.ID))
lines = append(lines, fmt.Sprintf("Severity bumped: %s → %s", result.OldSeverity, result.NewSeverity))
lines = append(lines, fmt.Sprintf("Reescalation #%d", result.ReescalationNum))
lines = append(lines, fmt.Sprintf("Reescalated by: %s", reescalatedBy))
lines = append(lines, "")
lines = append(lines, "This escalation was not acknowledged within the stale threshold and has been automatically re-escalated to a higher severity.")
lines = append(lines, "")
lines = append(lines, "---")
lines = append(lines, "To acknowledge: gt escalate ack "+result.ID)
lines = append(lines, "To close: gt escalate close "+result.ID+" --reason \"resolution\"")
return strings.Join(lines, "\n")
}
func runEscalateShow(cmd *cobra.Command, args []string) error {
escalationID := args[0]
townRoot, err := workspace.FindFromCwdOrError()
if err != nil {
return fmt.Errorf("not in a Gas Town workspace: %w", err)
}
bd := beads.New(beads.ResolveBeadsDir(townRoot))
issue, fields, err := bd.GetEscalationBead(escalationID)
if err != nil {
return fmt.Errorf("getting escalation: %w", err)
}
if issue == nil {
return fmt.Errorf("escalation not found: %s", escalationID)
}
if escalateJSON {
data := map[string]interface{}{
"id": issue.ID,
"title": issue.Title,
"status": issue.Status,
"created_at": issue.CreatedAt,
"severity": fields.Severity,
"reason": fields.Reason,
"escalatedBy": fields.EscalatedBy,
"escalatedAt": fields.EscalatedAt,
"ackedBy": fields.AckedBy,
"ackedAt": fields.AckedAt,
"closedBy": fields.ClosedBy,
"closedReason": fields.ClosedReason,
"relatedBead": fields.RelatedBead,
}
out, _ := json.MarshalIndent(data, "", " ")
fmt.Println(string(out))
return nil
}
emoji := severityEmoji(fields.Severity)
fmt.Printf("%s Escalation: %s\n", emoji, issue.ID)
fmt.Printf(" Title: %s\n", issue.Title)
fmt.Printf(" Status: %s\n", issue.Status)
fmt.Printf(" Severity: %s\n", fields.Severity)
fmt.Printf(" Created: %s\n", formatRelativeTime(issue.CreatedAt))
fmt.Printf(" Escalated by: %s\n", fields.EscalatedBy)
if fields.Reason != "" {
fmt.Printf(" Reason: %s\n", fields.Reason)
}
if fields.AckedBy != "" {
fmt.Printf(" Acknowledged by: %s at %s\n", fields.AckedBy, fields.AckedAt)
}
if fields.ClosedBy != "" {
fmt.Printf(" Closed by: %s\n", fields.ClosedBy)
fmt.Printf(" Resolution: %s\n", fields.ClosedReason)
}
if fields.RelatedBead != "" {
fmt.Printf(" Related: %s\n", fields.RelatedBead)
}
return nil
}
// Helper functions
// extractMailTargetsFromActions extracts mail targets from action strings.
// Action format: "mail:target" returns "target"
// E.g., ["bead", "mail:mayor", "email:human"] returns ["mayor"]
func extractMailTargetsFromActions(actions []string) []string {
var targets []string
for _, action := range actions {
if strings.HasPrefix(action, "mail:") {
target := strings.TrimPrefix(action, "mail:")
if target != "" {
targets = append(targets, target)
}
}
}
return targets
}
// executeExternalActions processes external notification actions (email:, sms:, slack).
// For now, this logs warnings if contacts aren't configured - actual sending is future work.
func executeExternalActions(actions []string, cfg *config.EscalationConfig, _, _, _ string) {
for _, action := range actions {
switch {
case strings.HasPrefix(action, "email:"):
if cfg.Contacts.HumanEmail == "" {
style.PrintWarning("email action '%s' skipped: contacts.human_email not configured in settings/escalation.json", action)
} else {
// TODO: Implement actual email sending
fmt.Printf(" 📧 Would send email to %s (not yet implemented)\n", cfg.Contacts.HumanEmail)
}
case strings.HasPrefix(action, "sms:"):
if cfg.Contacts.HumanSMS == "" {
style.PrintWarning("sms action '%s' skipped: contacts.human_sms not configured in settings/escalation.json", action)
} else {
// TODO: Implement actual SMS sending
fmt.Printf(" 📱 Would send SMS to %s (not yet implemented)\n", cfg.Contacts.HumanSMS)
}
case action == "slack":
if cfg.Contacts.SlackWebhook == "" {
style.PrintWarning("slack action skipped: contacts.slack_webhook not configured in settings/escalation.json")
} else {
// TODO: Implement actual Slack webhook posting
fmt.Printf(" 💬 Would post to Slack (not yet implemented)\n")
}
case action == "log":
// Log action always succeeds - writes to escalation log file
// TODO: Implement actual log file writing
fmt.Printf(" 📝 Logged to escalation log\n")
}
}
}
func formatEscalationMailBody(beadID, severity, reason, from, related string) string {
var lines []string
lines = append(lines, fmt.Sprintf("Escalation ID: %s", beadID))
lines = append(lines, fmt.Sprintf("Severity: %s", severity))
lines = append(lines, fmt.Sprintf("From: %s", from))
if reason != "" {
lines = append(lines, "")
lines = append(lines, "Reason:")
lines = append(lines, reason)
}
if related != "" {
lines = append(lines, "")
lines = append(lines, fmt.Sprintf("Related: %s", related))
}
lines = append(lines, "")
lines = append(lines, "---")
lines = append(lines, "To acknowledge: gt escalate ack "+beadID)
lines = append(lines, "To close: gt escalate close "+beadID+" --reason \"resolution\"")
return strings.Join(lines, "\n")
}
func severityEmoji(severity string) string {
switch severity {
case config.SeverityCritical:
return "🚨"
case config.SeverityHigh:
return "⚠️"
case config.SeverityMedium:
return "📢"
case config.SeverityLow:
return ""
default:
return "📋"
}
}
func formatRelativeTime(timestamp string) string {
t, err := time.Parse(time.RFC3339, timestamp)
if err != nil {
return timestamp
}
duration := time.Since(t)
if duration < time.Minute {
return "just now"
}
if duration < time.Hour {
mins := int(duration.Minutes())
if mins == 1 {
return "1 minute ago"
}
return fmt.Sprintf("%d minutes ago", mins)
}
if duration < 24*time.Hour {
hours := int(duration.Hours())
if hours == 1 {
return "1 hour ago"
}
return fmt.Sprintf("%d hours ago", hours)
}
days := int(duration.Hours() / 24)
if days == 1 {
return "1 day ago"
}
return fmt.Sprintf("%d days ago", days)
}
// detectSender is defined in mail_send.go - we reuse it here
// If it's not accessible, we fall back to environment variables
func detectSenderFallback() string {
// Try BD_ACTOR first (most common in agent context)
if actor := os.Getenv("BD_ACTOR"); actor != "" {
return actor
}
// Try GT_ROLE
if role := os.Getenv("GT_ROLE"); role != "" {
return role
}
return ""
}