feat: Implement unified escalation system (gt-i9r20)
Add severity-based routing for escalations with config-driven targets. Changes: - EscalationConfig type with severity routes and external channels - beads/beads_escalation.go: Escalation bead operations (create/ack/close/list) - Refactored gt escalate command with subcommands: - list: Show open escalations - ack: Acknowledge an escalation - close: Resolve with reason - stale: Find unacknowledged escalations past threshold - show: Display escalation details - Added TypeEscalationAcked and TypeEscalationClosed event types Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,254 +1,158 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/gastown/internal/events"
|
||||
"github.com/steveyegge/gastown/internal/mail"
|
||||
"github.com/steveyegge/gastown/internal/style"
|
||||
"github.com/steveyegge/gastown/internal/workspace"
|
||||
)
|
||||
|
||||
// Escalation severity levels.
|
||||
// These map to mail priorities and indicate urgency for human attention.
|
||||
const (
|
||||
// SeverityCritical (P0) - System-threatening issues requiring immediate human attention.
|
||||
// Examples: data corruption, security breach, complete system failure.
|
||||
SeverityCritical = "CRITICAL"
|
||||
|
||||
// SeverityHigh (P1) - Important blockers that need human attention soon.
|
||||
// Examples: unresolvable merge conflicts, critical blocking bugs, ambiguous requirements.
|
||||
SeverityHigh = "HIGH"
|
||||
|
||||
// SeverityMedium (P2) - Standard escalations for human attention at convenience.
|
||||
// Examples: unclear requirements, design decisions needed, non-blocking issues.
|
||||
SeverityMedium = "MEDIUM"
|
||||
// Escalate command flags
|
||||
var (
|
||||
escalateSeverity string
|
||||
escalateReason string
|
||||
escalateRelatedBead string
|
||||
escalateJSON bool
|
||||
escalateListJSON bool
|
||||
escalateListAll bool
|
||||
escalateStaleJSON bool
|
||||
escalateDryRun bool
|
||||
escalateCloseReason string
|
||||
)
|
||||
|
||||
var escalateCmd = &cobra.Command{
|
||||
Use: "escalate <topic>",
|
||||
Use: "escalate [description]",
|
||||
GroupID: GroupComm,
|
||||
Short: "Escalate an issue to the human overseer",
|
||||
Long: `Escalate an issue to the human overseer for attention.
|
||||
Short: "Escalation system for critical issues",
|
||||
RunE: runEscalate,
|
||||
Long: `Create and manage escalations for critical issues.
|
||||
|
||||
This is the structured escalation channel for Gas Town. Any agent can use this
|
||||
to request human intervention when automated resolution isn't possible.
|
||||
The escalation system provides severity-based routing for issues that need
|
||||
human or mayor attention. Escalations are tracked as beads with gt:escalation label.
|
||||
|
||||
Severity levels:
|
||||
CRITICAL (P0) - System-threatening, immediate attention required
|
||||
Examples: data corruption, security breach, system down
|
||||
HIGH (P1) - Important blocker, needs human soon
|
||||
Examples: unresolvable conflict, critical bug, ambiguous spec
|
||||
MEDIUM (P2) - Standard escalation, human attention at convenience
|
||||
Examples: design decision needed, unclear requirements
|
||||
SEVERITY LEVELS:
|
||||
critical (P0) Immediate attention required
|
||||
high (P1) Urgent, needs attention soon
|
||||
normal (P2) Standard escalation (default)
|
||||
low (P3) Informational, can wait
|
||||
|
||||
The escalation creates an audit trail bead and sends mail to the overseer
|
||||
with appropriate priority. All molecular algebra edge cases should escalate
|
||||
here rather than failing silently.
|
||||
WORKFLOW:
|
||||
1. Agent encounters blocking issue
|
||||
2. Runs: gt escalate "Description" --severity high --reason "details"
|
||||
3. Escalation is routed based on config/escalation.json
|
||||
4. Recipient acknowledges with: gt escalate ack <id>
|
||||
5. After resolution: gt escalate close <id> --reason "fixed"
|
||||
|
||||
CONFIGURATION:
|
||||
Routing is configured in ~/gt/config/escalation.json:
|
||||
- severity_routes: Map severity to notification targets
|
||||
- external_channels: Optional email/SMS for critical issues
|
||||
- stale_threshold: When unacked escalations are flagged
|
||||
|
||||
Examples:
|
||||
gt escalate "Database migration failed"
|
||||
gt escalate -s CRITICAL "Data corruption detected in user table"
|
||||
gt escalate -s HIGH "Merge conflict cannot be resolved automatically"
|
||||
gt escalate -s MEDIUM "Need clarification on API design" -m "Details here..."`,
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
RunE: runEscalate,
|
||||
gt escalate "Build failing" --severity critical --reason "CI blocked"
|
||||
gt escalate "Need API credentials" --severity high
|
||||
gt escalate "Code review requested" --reason "PR #123 ready"
|
||||
gt escalate list # Show open escalations
|
||||
gt escalate ack hq-abc123 # Acknowledge
|
||||
gt escalate close hq-abc123 --reason "Fixed in commit abc"
|
||||
gt escalate stale # Show unacked escalations`,
|
||||
}
|
||||
|
||||
var (
|
||||
escalateSeverity string
|
||||
escalateMessage string
|
||||
escalateDryRun bool
|
||||
)
|
||||
var escalateListCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List open escalations",
|
||||
Long: `List all open escalations.
|
||||
|
||||
Shows escalations that haven't been closed yet. Use --all to include
|
||||
closed escalations.
|
||||
|
||||
Examples:
|
||||
gt escalate list # Open escalations only
|
||||
gt escalate list --all # Include closed
|
||||
gt escalate list --json # JSON output`,
|
||||
RunE: runEscalateList,
|
||||
}
|
||||
|
||||
var escalateAckCmd = &cobra.Command{
|
||||
Use: "ack <escalation-id>",
|
||||
Short: "Acknowledge an escalation",
|
||||
Long: `Acknowledge an escalation to indicate you're working on it.
|
||||
|
||||
Adds an "acked" label and records who acknowledged and when.
|
||||
This stops the stale escalation warnings.
|
||||
|
||||
Examples:
|
||||
gt escalate ack hq-abc123`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runEscalateAck,
|
||||
}
|
||||
|
||||
var escalateCloseCmd = &cobra.Command{
|
||||
Use: "close <escalation-id>",
|
||||
Short: "Close a resolved escalation",
|
||||
Long: `Close an escalation after the issue is resolved.
|
||||
|
||||
Records who closed it and the resolution reason.
|
||||
|
||||
Examples:
|
||||
gt escalate close hq-abc123 --reason "Fixed in commit abc"
|
||||
gt escalate close hq-abc123 --reason "Not reproducible"`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runEscalateClose,
|
||||
}
|
||||
|
||||
var escalateStaleCmd = &cobra.Command{
|
||||
Use: "stale",
|
||||
Short: "Show stale unacknowledged escalations",
|
||||
Long: `Show escalations that haven't been acknowledged within the threshold.
|
||||
|
||||
The threshold is configured in config/escalation.json (default: 1 hour).
|
||||
Useful for patrol agents to detect escalations that need attention.
|
||||
|
||||
Examples:
|
||||
gt escalate stale # Show stale escalations
|
||||
gt escalate stale --json # JSON output`,
|
||||
RunE: runEscalateStale,
|
||||
}
|
||||
|
||||
var escalateShowCmd = &cobra.Command{
|
||||
Use: "show <escalation-id>",
|
||||
Short: "Show details of an escalation",
|
||||
Long: `Display detailed information about an escalation.
|
||||
|
||||
Examples:
|
||||
gt escalate show hq-abc123
|
||||
gt escalate show hq-abc123 --json`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runEscalateShow,
|
||||
}
|
||||
|
||||
func init() {
|
||||
escalateCmd.Flags().StringVarP(&escalateSeverity, "severity", "s", SeverityMedium,
|
||||
"Severity level: CRITICAL, HIGH, or MEDIUM")
|
||||
escalateCmd.Flags().StringVarP(&escalateMessage, "message", "m", "",
|
||||
"Additional details about the escalation")
|
||||
escalateCmd.Flags().BoolVarP(&escalateDryRun, "dry-run", "n", false,
|
||||
"Show what would be done without executing")
|
||||
// Main escalate command flags
|
||||
escalateCmd.Flags().StringVarP(&escalateSeverity, "severity", "s", "normal", "Severity level: critical, high, normal, low")
|
||||
escalateCmd.Flags().StringVarP(&escalateReason, "reason", "r", "", "Detailed reason for escalation")
|
||||
escalateCmd.Flags().StringVar(&escalateRelatedBead, "related", "", "Related bead ID (task, bug, etc.)")
|
||||
escalateCmd.Flags().BoolVar(&escalateJSON, "json", false, "Output as JSON")
|
||||
escalateCmd.Flags().BoolVarP(&escalateDryRun, "dry-run", "n", false, "Show what would be done without executing")
|
||||
|
||||
// List subcommand flags
|
||||
escalateListCmd.Flags().BoolVar(&escalateListJSON, "json", false, "Output as JSON")
|
||||
escalateListCmd.Flags().BoolVar(&escalateListAll, "all", false, "Include closed escalations")
|
||||
|
||||
// Close subcommand flags
|
||||
escalateCloseCmd.Flags().StringVar(&escalateCloseReason, "reason", "", "Resolution reason")
|
||||
_ = escalateCloseCmd.MarkFlagRequired("reason")
|
||||
|
||||
// Stale subcommand flags
|
||||
escalateStaleCmd.Flags().BoolVar(&escalateStaleJSON, "json", false, "Output as JSON")
|
||||
|
||||
// Show subcommand flags
|
||||
escalateShowCmd.Flags().BoolVar(&escalateJSON, "json", false, "Output as JSON")
|
||||
|
||||
// Add subcommands
|
||||
escalateCmd.AddCommand(escalateListCmd)
|
||||
escalateCmd.AddCommand(escalateAckCmd)
|
||||
escalateCmd.AddCommand(escalateCloseCmd)
|
||||
escalateCmd.AddCommand(escalateStaleCmd)
|
||||
escalateCmd.AddCommand(escalateShowCmd)
|
||||
|
||||
rootCmd.AddCommand(escalateCmd)
|
||||
}
|
||||
|
||||
func runEscalate(cmd *cobra.Command, args []string) error {
|
||||
topic := strings.Join(args, " ")
|
||||
|
||||
// Validate severity
|
||||
severity := strings.ToUpper(escalateSeverity)
|
||||
if severity != SeverityCritical && severity != SeverityHigh && severity != SeverityMedium {
|
||||
return fmt.Errorf("invalid severity '%s': must be CRITICAL, HIGH, or MEDIUM", escalateSeverity)
|
||||
}
|
||||
|
||||
// Map severity to mail priority
|
||||
var priority mail.Priority
|
||||
switch severity {
|
||||
case SeverityCritical:
|
||||
priority = mail.PriorityUrgent
|
||||
case SeverityHigh:
|
||||
priority = mail.PriorityHigh
|
||||
default:
|
||||
priority = mail.PriorityNormal
|
||||
}
|
||||
|
||||
// Find workspace
|
||||
townRoot, err := workspace.FindFromCwdOrError()
|
||||
if err != nil {
|
||||
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
||||
}
|
||||
|
||||
// Detect agent identity
|
||||
agentID, err := detectAgentIdentity()
|
||||
if err != nil {
|
||||
agentID = "unknown"
|
||||
}
|
||||
|
||||
// Build mail subject with severity tag
|
||||
subject := fmt.Sprintf("[%s] %s", severity, topic)
|
||||
|
||||
// Build mail body
|
||||
var bodyParts []string
|
||||
bodyParts = append(bodyParts, fmt.Sprintf("Escalated by: %s", agentID))
|
||||
bodyParts = append(bodyParts, fmt.Sprintf("Severity: %s", severity))
|
||||
if escalateMessage != "" {
|
||||
bodyParts = append(bodyParts, "")
|
||||
bodyParts = append(bodyParts, escalateMessage)
|
||||
}
|
||||
body := strings.Join(bodyParts, "\n")
|
||||
|
||||
// Dry run mode
|
||||
if escalateDryRun {
|
||||
fmt.Printf("Would create escalation:\n")
|
||||
fmt.Printf(" Severity: %s\n", severity)
|
||||
fmt.Printf(" Priority: %s\n", priority)
|
||||
fmt.Printf(" Subject: %s\n", subject)
|
||||
fmt.Printf(" Body:\n%s\n", indentText(body, " "))
|
||||
fmt.Printf("Would send mail to: overseer\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create escalation bead for audit trail
|
||||
beadID, err := createEscalationBead(topic, severity, agentID, escalateMessage)
|
||||
if err != nil {
|
||||
// Non-fatal - escalation mail is more important
|
||||
style.PrintWarning("could not create escalation bead: %v", err)
|
||||
} else {
|
||||
fmt.Printf("%s Created escalation bead: %s\n", style.Bold.Render("📋"), beadID)
|
||||
}
|
||||
|
||||
// Send mail to overseer
|
||||
router := mail.NewRouter(townRoot)
|
||||
msg := &mail.Message{
|
||||
From: agentID,
|
||||
To: "overseer",
|
||||
Subject: subject,
|
||||
Body: body,
|
||||
Priority: priority,
|
||||
}
|
||||
|
||||
if err := router.Send(msg); err != nil {
|
||||
return fmt.Errorf("sending escalation mail: %w", err)
|
||||
}
|
||||
|
||||
// Log to activity feed
|
||||
payload := events.EscalationPayload("", agentID, "overseer", topic)
|
||||
payload["severity"] = severity
|
||||
if beadID != "" {
|
||||
payload["bead"] = beadID
|
||||
}
|
||||
_ = events.LogFeed(events.TypeEscalationSent, agentID, payload)
|
||||
|
||||
// Print confirmation with severity-appropriate styling
|
||||
var emoji string
|
||||
switch severity {
|
||||
case SeverityCritical:
|
||||
emoji = "🚨"
|
||||
case SeverityHigh:
|
||||
emoji = "⚠️"
|
||||
default:
|
||||
emoji = "📢"
|
||||
}
|
||||
|
||||
fmt.Printf("%s Escalation sent to overseer [%s]\n", emoji, severity)
|
||||
fmt.Printf(" Topic: %s\n", topic)
|
||||
if beadID != "" {
|
||||
fmt.Printf(" Bead: %s\n", beadID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// detectAgentIdentity returns the current agent's identity string.
|
||||
func detectAgentIdentity() (string, error) {
|
||||
// Try GT_ROLE first
|
||||
if role := os.Getenv("GT_ROLE"); role != "" {
|
||||
return role, nil
|
||||
}
|
||||
|
||||
// Try to detect from cwd
|
||||
agentID, _, _, err := resolveSelfTarget()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return agentID, nil
|
||||
}
|
||||
|
||||
// createEscalationBead creates a bead to track the escalation.
|
||||
func createEscalationBead(topic, severity, from, details string) (string, error) {
|
||||
// Use bd create to make the escalation bead
|
||||
args := []string{
|
||||
"create",
|
||||
"--title", fmt.Sprintf("[ESCALATION] %s", topic),
|
||||
"--type", "task", // Use task type since escalation isn't a standard type
|
||||
"--priority", severityToBeadsPriority(severity),
|
||||
}
|
||||
|
||||
// Add description with escalation metadata
|
||||
desc := fmt.Sprintf("Escalation from: %s\nSeverity: %s\n", from, severity)
|
||||
if details != "" {
|
||||
desc += "\n" + details
|
||||
}
|
||||
args = append(args, "--description", desc)
|
||||
|
||||
// Add tag for filtering
|
||||
args = append(args, "--tag", "escalation")
|
||||
|
||||
cmd := exec.Command("bd", args...)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("bd create: %w", err)
|
||||
}
|
||||
|
||||
// Parse bead ID from output (bd create outputs: "Created bead: gt-xxxxx")
|
||||
output := strings.TrimSpace(string(out))
|
||||
parts := strings.Split(output, ": ")
|
||||
if len(parts) >= 2 {
|
||||
return strings.TrimSpace(parts[len(parts)-1]), nil
|
||||
}
|
||||
return "", fmt.Errorf("could not parse bead ID from: %s", output)
|
||||
}
|
||||
|
||||
// severityToBeadsPriority converts severity to beads priority string.
|
||||
func severityToBeadsPriority(severity string) string {
|
||||
switch severity {
|
||||
case SeverityCritical:
|
||||
return "0" // P0
|
||||
case SeverityHigh:
|
||||
return "1" // P1
|
||||
default:
|
||||
return "2" // P2
|
||||
}
|
||||
}
|
||||
|
||||
// indentText indents each line of text with the given prefix.
|
||||
func indentText(text, prefix string) string {
|
||||
lines := strings.Split(text, "\n")
|
||||
for i, line := range lines {
|
||||
lines[i] = prefix + line
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user