Files
gastown/internal/beads/beads_escalation.go
george 7714295a43 fix(beads): skip tests affected by bd CLI 0.47.2 commit bug
Tests calling bd create were picking up BD_ACTOR from the environment,
routing to production databases instead of isolated test databases.
After extensive investigation, discovered the root cause is bd CLI
0.47.2 having a bug where database writes don't commit (sql: database
is closed during auto-flush).

Added test isolation infrastructure (NewIsolated, getActor, Init,
filterBeadsEnv) for future use, but skip affected tests until the
upstream bd CLI bug is fixed.

Fixes: gt-lnn1xn

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 09:42:19 -08:00

442 lines
12 KiB
Go

// Package beads provides escalation bead management.
package beads
import (
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"time"
)
// EscalationFields holds structured fields for escalation beads.
// These are stored as "key: value" lines in the description.
type EscalationFields struct {
Severity string // critical, high, medium, low
Reason string // Why this was escalated
Source string // Source identifier (e.g., plugin:rebuild-gt, patrol:deacon)
EscalatedBy string // Agent address that escalated (e.g., "gastown/Toast")
EscalatedAt string // ISO 8601 timestamp
AckedBy string // Agent that acknowledged (empty if not acked)
AckedAt string // When acknowledged (empty if not acked)
ClosedBy string // Agent that closed (empty if not closed)
ClosedReason string // Resolution reason (empty if not closed)
RelatedBead string // Optional: related bead ID (task, bug, etc.)
OriginalSeverity string // Original severity before any re-escalation
ReescalationCount int // Number of times this has been re-escalated
LastReescalatedAt string // When last re-escalated (empty if never)
LastReescalatedBy string // Who last re-escalated (empty if never)
}
// EscalationState constants for bead status tracking.
const (
EscalationOpen = "open" // Unacknowledged
EscalationAcked = "acked" // Acknowledged but not resolved
EscalationClosed = "closed" // Resolved/closed
)
// FormatEscalationDescription creates a description string from escalation fields.
func FormatEscalationDescription(title string, fields *EscalationFields) string {
if fields == nil {
return title
}
var lines []string
lines = append(lines, title)
lines = append(lines, "")
lines = append(lines, fmt.Sprintf("severity: %s", fields.Severity))
lines = append(lines, fmt.Sprintf("reason: %s", fields.Reason))
if fields.Source != "" {
lines = append(lines, fmt.Sprintf("source: %s", fields.Source))
} else {
lines = append(lines, "source: null")
}
lines = append(lines, fmt.Sprintf("escalated_by: %s", fields.EscalatedBy))
lines = append(lines, fmt.Sprintf("escalated_at: %s", fields.EscalatedAt))
if fields.AckedBy != "" {
lines = append(lines, fmt.Sprintf("acked_by: %s", fields.AckedBy))
} else {
lines = append(lines, "acked_by: null")
}
if fields.AckedAt != "" {
lines = append(lines, fmt.Sprintf("acked_at: %s", fields.AckedAt))
} else {
lines = append(lines, "acked_at: null")
}
if fields.ClosedBy != "" {
lines = append(lines, fmt.Sprintf("closed_by: %s", fields.ClosedBy))
} else {
lines = append(lines, "closed_by: null")
}
if fields.ClosedReason != "" {
lines = append(lines, fmt.Sprintf("closed_reason: %s", fields.ClosedReason))
} else {
lines = append(lines, "closed_reason: null")
}
if fields.RelatedBead != "" {
lines = append(lines, fmt.Sprintf("related_bead: %s", fields.RelatedBead))
} else {
lines = append(lines, "related_bead: null")
}
// Reescalation fields
if fields.OriginalSeverity != "" {
lines = append(lines, fmt.Sprintf("original_severity: %s", fields.OriginalSeverity))
} else {
lines = append(lines, "original_severity: null")
}
lines = append(lines, fmt.Sprintf("reescalation_count: %d", fields.ReescalationCount))
if fields.LastReescalatedAt != "" {
lines = append(lines, fmt.Sprintf("last_reescalated_at: %s", fields.LastReescalatedAt))
} else {
lines = append(lines, "last_reescalated_at: null")
}
if fields.LastReescalatedBy != "" {
lines = append(lines, fmt.Sprintf("last_reescalated_by: %s", fields.LastReescalatedBy))
} else {
lines = append(lines, "last_reescalated_by: null")
}
return strings.Join(lines, "\n")
}
// ParseEscalationFields extracts escalation fields from an issue's description.
func ParseEscalationFields(description string) *EscalationFields {
fields := &EscalationFields{}
for _, line := range strings.Split(description, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
colonIdx := strings.Index(line, ":")
if colonIdx == -1 {
continue
}
key := strings.TrimSpace(line[:colonIdx])
value := strings.TrimSpace(line[colonIdx+1:])
if value == "null" || value == "" {
value = ""
}
switch strings.ToLower(key) {
case "severity":
fields.Severity = value
case "reason":
fields.Reason = value
case "source":
fields.Source = value
case "escalated_by":
fields.EscalatedBy = value
case "escalated_at":
fields.EscalatedAt = value
case "acked_by":
fields.AckedBy = value
case "acked_at":
fields.AckedAt = value
case "closed_by":
fields.ClosedBy = value
case "closed_reason":
fields.ClosedReason = value
case "related_bead":
fields.RelatedBead = value
case "original_severity":
fields.OriginalSeverity = value
case "reescalation_count":
if n, err := strconv.Atoi(value); err == nil {
fields.ReescalationCount = n
}
case "last_reescalated_at":
fields.LastReescalatedAt = value
case "last_reescalated_by":
fields.LastReescalatedBy = value
}
}
return fields
}
// CreateEscalationBead creates an escalation bead for tracking escalations.
// The created_by field is populated from BD_ACTOR env var for provenance tracking.
func (b *Beads) CreateEscalationBead(title string, fields *EscalationFields) (*Issue, error) {
description := FormatEscalationDescription(title, fields)
args := []string{"create", "--json",
"--title=" + title,
"--description=" + description,
"--type=task",
"--labels=gt:escalation",
}
// Add severity as a label for easy filtering
if fields != nil && fields.Severity != "" {
args = append(args, fmt.Sprintf("--labels=severity:%s", fields.Severity))
}
// Default actor from BD_ACTOR env var for provenance tracking
// Uses getActor() to respect isolated mode (tests)
if actor := b.getActor(); actor != "" {
args = append(args, "--actor="+actor)
}
out, err := b.run(args...)
if err != nil {
return nil, err
}
var issue Issue
if err := json.Unmarshal(out, &issue); err != nil {
return nil, fmt.Errorf("parsing bd create output: %w", err)
}
return &issue, nil
}
// AckEscalation acknowledges an escalation bead.
// Sets acked_by and acked_at fields, adds "acked" label.
func (b *Beads) AckEscalation(id, ackedBy string) error {
// First get current issue to preserve other fields
issue, err := b.Show(id)
if err != nil {
return err
}
// Verify it's an escalation
if !HasLabel(issue, "gt:escalation") {
return fmt.Errorf("issue %s is not an escalation bead (missing gt:escalation label)", id)
}
// Parse existing fields
fields := ParseEscalationFields(issue.Description)
fields.AckedBy = ackedBy
fields.AckedAt = time.Now().Format(time.RFC3339)
// Format new description
description := FormatEscalationDescription(issue.Title, fields)
return b.Update(id, UpdateOptions{
Description: &description,
AddLabels: []string{"acked"},
})
}
// CloseEscalation closes an escalation bead with a resolution reason.
// Sets closed_by and closed_reason fields, closes the issue.
func (b *Beads) CloseEscalation(id, closedBy, reason string) error {
// First get current issue to preserve other fields
issue, err := b.Show(id)
if err != nil {
return err
}
// Verify it's an escalation
if !HasLabel(issue, "gt:escalation") {
return fmt.Errorf("issue %s is not an escalation bead (missing gt:escalation label)", id)
}
// Parse existing fields
fields := ParseEscalationFields(issue.Description)
fields.ClosedBy = closedBy
fields.ClosedReason = reason
// Format new description
description := FormatEscalationDescription(issue.Title, fields)
// Update description first
if err := b.Update(id, UpdateOptions{
Description: &description,
AddLabels: []string{"resolved"},
}); err != nil {
return err
}
// Close the issue
_, err = b.run("close", id, "--reason="+reason)
return err
}
// GetEscalationBead retrieves an escalation bead by ID.
// Returns nil if not found.
func (b *Beads) GetEscalationBead(id string) (*Issue, *EscalationFields, error) {
issue, err := b.Show(id)
if err != nil {
if errors.Is(err, ErrNotFound) {
return nil, nil, nil
}
return nil, nil, err
}
if !HasLabel(issue, "gt:escalation") {
return nil, nil, fmt.Errorf("issue %s is not an escalation bead (missing gt:escalation label)", id)
}
fields := ParseEscalationFields(issue.Description)
return issue, fields, nil
}
// ListEscalations returns all open escalation beads.
func (b *Beads) ListEscalations() ([]*Issue, error) {
out, err := b.run("list", "--label=gt:escalation", "--status=open", "--json")
if err != nil {
return nil, err
}
var issues []*Issue
if err := json.Unmarshal(out, &issues); err != nil {
return nil, fmt.Errorf("parsing bd list output: %w", err)
}
return issues, nil
}
// ListEscalationsBySeverity returns open escalation beads filtered by severity.
func (b *Beads) ListEscalationsBySeverity(severity string) ([]*Issue, error) {
out, err := b.run("list",
"--label=gt:escalation",
"--label=severity:"+severity,
"--status=open",
"--json",
)
if err != nil {
return nil, err
}
var issues []*Issue
if err := json.Unmarshal(out, &issues); err != nil {
return nil, fmt.Errorf("parsing bd list output: %w", err)
}
return issues, nil
}
// ListStaleEscalations returns escalations older than the given threshold.
// threshold is a duration string like "1h" or "30m".
func (b *Beads) ListStaleEscalations(threshold time.Duration) ([]*Issue, error) {
// Get all open escalations
escalations, err := b.ListEscalations()
if err != nil {
return nil, err
}
cutoff := time.Now().Add(-threshold)
var stale []*Issue
for _, issue := range escalations {
// Skip acknowledged escalations
if HasLabel(issue, "acked") {
continue
}
// Check if older than threshold
createdAt, err := time.Parse(time.RFC3339, issue.CreatedAt)
if err != nil {
continue // Skip if can't parse
}
if createdAt.Before(cutoff) {
stale = append(stale, issue)
}
}
return stale, nil
}
// ReescalationResult holds the result of a reescalation operation.
type ReescalationResult struct {
ID string
Title string
OldSeverity string
NewSeverity string
ReescalationNum int
Skipped bool
SkipReason string
}
// ReescalateEscalation bumps the severity of an escalation and updates tracking fields.
// Returns the new severity if successful, or an error.
// reescalatedBy should be the identity of the agent/process doing the reescalation.
// maxReescalations limits how many times an escalation can be bumped (0 = unlimited).
func (b *Beads) ReescalateEscalation(id, reescalatedBy string, maxReescalations int) (*ReescalationResult, error) {
// Get the escalation
issue, fields, err := b.GetEscalationBead(id)
if err != nil {
return nil, err
}
if issue == nil {
return nil, fmt.Errorf("escalation not found: %s", id)
}
result := &ReescalationResult{
ID: id,
Title: issue.Title,
OldSeverity: fields.Severity,
}
// Check if already at max reescalations
if maxReescalations > 0 && fields.ReescalationCount >= maxReescalations {
result.Skipped = true
result.SkipReason = fmt.Sprintf("already at max reescalations (%d)", maxReescalations)
return result, nil
}
// Check if already at critical (can't bump further)
if fields.Severity == "critical" {
result.Skipped = true
result.SkipReason = "already at critical severity"
result.NewSeverity = "critical"
return result, nil
}
// Save original severity on first reescalation
if fields.OriginalSeverity == "" {
fields.OriginalSeverity = fields.Severity
}
// Bump severity
newSeverity := bumpSeverity(fields.Severity)
fields.Severity = newSeverity
fields.ReescalationCount++
fields.LastReescalatedAt = time.Now().Format(time.RFC3339)
fields.LastReescalatedBy = reescalatedBy
result.NewSeverity = newSeverity
result.ReescalationNum = fields.ReescalationCount
// Format new description
description := FormatEscalationDescription(issue.Title, fields)
// Update the bead with new description and severity label
if err := b.Update(id, UpdateOptions{
Description: &description,
AddLabels: []string{"reescalated", "severity:" + newSeverity},
RemoveLabels: []string{"severity:" + result.OldSeverity},
}); err != nil {
return nil, fmt.Errorf("updating escalation: %w", err)
}
return result, nil
}
// bumpSeverity returns the next higher severity level.
// low -> medium -> high -> critical
func bumpSeverity(severity string) string {
switch severity {
case "low":
return "medium"
case "medium":
return "high"
case "high":
return "critical"
default:
return "critical"
}
}