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>
442 lines
12 KiB
Go
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"
|
|
}
|
|
}
|