When the Refinery detects a build error or test failure and refuses to merge, the polecat was never notified. This fixes the notification pipeline by: 1. Adding MERGE_FAILED protocol support to Witness: - PatternMergeFailed regex pattern - ProtoMergeFailed protocol type constant - MergeFailedPayload struct with all failure details - ParseMergeFailed parser function - ClassifyMessage case for MERGE_FAILED 2. Adding HandleMergeFailed handler to Witness: - Parses the failure notification - Sends HIGH priority mail to polecat with fix instructions - Includes branch, issue, failure type, and error details 3. Adding mail notification in Refinery's handleFailureFromQueue: - Creates mail.Router for sending protocol messages - Sends MERGE_FAILED to Witness when merge fails - Includes failure type (build/tests/conflict) and error 4. Adding comprehensive unit tests: - TestParseMergeFailed for full body parsing - TestParseMergeFailed_MinimalBody for minimal body - TestParseMergeFailed_InvalidSubject for error handling - ClassifyMessage test cases for MERGE_FAILED Fixes #114 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
362 lines
11 KiB
Go
362 lines
11 KiB
Go
// Package witness provides the polecat monitoring agent.
|
|
package witness
|
|
|
|
import (
|
|
"fmt"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Protocol message patterns for Witness inbox routing.
|
|
var (
|
|
// POLECAT_DONE <name> - polecat signaling work completion
|
|
PatternPolecatDone = regexp.MustCompile(`^POLECAT_DONE\s+(\S+)`)
|
|
|
|
// LIFECYCLE:Shutdown <name> - daemon-triggered polecat shutdown
|
|
PatternLifecycleShutdown = regexp.MustCompile(`^LIFECYCLE:Shutdown\s+(\S+)`)
|
|
|
|
// HELP: <topic> - polecat requesting intervention
|
|
PatternHelp = regexp.MustCompile(`^HELP:\s+(.+)`)
|
|
|
|
// MERGED <name> - refinery confirms branch merged
|
|
PatternMerged = regexp.MustCompile(`^MERGED\s+(\S+)`)
|
|
|
|
// MERGE_FAILED <name> - refinery reporting merge failure
|
|
PatternMergeFailed = regexp.MustCompile(`^MERGE_FAILED\s+(\S+)`)
|
|
|
|
// HANDOFF - session continuity message
|
|
PatternHandoff = regexp.MustCompile(`^🤝\s*HANDOFF`)
|
|
|
|
// SWARM_START - mayor initiating batch work
|
|
PatternSwarmStart = regexp.MustCompile(`^SWARM_START`)
|
|
)
|
|
|
|
// ProtocolType identifies the type of protocol message.
|
|
type ProtocolType string
|
|
|
|
const (
|
|
ProtoPolecatDone ProtocolType = "polecat_done"
|
|
ProtoLifecycleShutdown ProtocolType = "lifecycle_shutdown"
|
|
ProtoHelp ProtocolType = "help"
|
|
ProtoMerged ProtocolType = "merged"
|
|
ProtoMergeFailed ProtocolType = "merge_failed"
|
|
ProtoHandoff ProtocolType = "handoff"
|
|
ProtoSwarmStart ProtocolType = "swarm_start"
|
|
ProtoUnknown ProtocolType = "unknown"
|
|
)
|
|
|
|
// PolecatDonePayload contains parsed data from a POLECAT_DONE message.
|
|
type PolecatDonePayload struct {
|
|
PolecatName string
|
|
Exit string // COMPLETED, ESCALATED, DEFERRED, PHASE_COMPLETE
|
|
IssueID string
|
|
MRID string
|
|
Branch string
|
|
Gate string // Gate ID when Exit is PHASE_COMPLETE
|
|
}
|
|
|
|
// HelpPayload contains parsed data from a HELP message.
|
|
type HelpPayload struct {
|
|
Topic string
|
|
Agent string
|
|
IssueID string
|
|
Problem string
|
|
Tried string
|
|
RequestedAt time.Time
|
|
}
|
|
|
|
// MergedPayload contains parsed data from a MERGED message.
|
|
type MergedPayload struct {
|
|
PolecatName string
|
|
Branch string
|
|
IssueID string
|
|
MergedAt time.Time
|
|
}
|
|
|
|
// MergeFailedPayload contains parsed data from a MERGE_FAILED message.
|
|
type MergeFailedPayload struct {
|
|
PolecatName string
|
|
Branch string
|
|
IssueID string
|
|
FailureType string // "build", "test", "lint", etc.
|
|
Error string
|
|
FailedAt time.Time
|
|
}
|
|
|
|
// SwarmStartPayload contains parsed data from a SWARM_START message.
|
|
type SwarmStartPayload struct {
|
|
SwarmID string
|
|
BeadIDs []string
|
|
Total int
|
|
StartedAt time.Time
|
|
}
|
|
|
|
// ClassifyMessage determines the protocol type from a message subject.
|
|
func ClassifyMessage(subject string) ProtocolType {
|
|
switch {
|
|
case PatternPolecatDone.MatchString(subject):
|
|
return ProtoPolecatDone
|
|
case PatternLifecycleShutdown.MatchString(subject):
|
|
return ProtoLifecycleShutdown
|
|
case PatternHelp.MatchString(subject):
|
|
return ProtoHelp
|
|
case PatternMerged.MatchString(subject):
|
|
return ProtoMerged
|
|
case PatternMergeFailed.MatchString(subject):
|
|
return ProtoMergeFailed
|
|
case PatternHandoff.MatchString(subject):
|
|
return ProtoHandoff
|
|
case PatternSwarmStart.MatchString(subject):
|
|
return ProtoSwarmStart
|
|
default:
|
|
return ProtoUnknown
|
|
}
|
|
}
|
|
|
|
// ParsePolecatDone extracts payload from a POLECAT_DONE message.
|
|
// Subject format: POLECAT_DONE <polecat-name>
|
|
// Body format:
|
|
//
|
|
// Exit: COMPLETED|ESCALATED|DEFERRED|PHASE_COMPLETE
|
|
// Issue: <issue-id>
|
|
// MR: <mr-id>
|
|
// Gate: <gate-id>
|
|
// Branch: <branch>
|
|
func ParsePolecatDone(subject, body string) (*PolecatDonePayload, error) {
|
|
matches := PatternPolecatDone.FindStringSubmatch(subject)
|
|
if len(matches) < 2 {
|
|
return nil, fmt.Errorf("invalid POLECAT_DONE subject: %s", subject)
|
|
}
|
|
|
|
payload := &PolecatDonePayload{
|
|
PolecatName: matches[1],
|
|
}
|
|
|
|
// Parse body for structured fields
|
|
for _, line := range strings.Split(body, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if strings.HasPrefix(line, "Exit:") {
|
|
payload.Exit = strings.TrimSpace(strings.TrimPrefix(line, "Exit:"))
|
|
} else if strings.HasPrefix(line, "Issue:") {
|
|
payload.IssueID = strings.TrimSpace(strings.TrimPrefix(line, "Issue:"))
|
|
} else if strings.HasPrefix(line, "MR:") {
|
|
payload.MRID = strings.TrimSpace(strings.TrimPrefix(line, "MR:"))
|
|
} else if strings.HasPrefix(line, "Gate:") {
|
|
payload.Gate = strings.TrimSpace(strings.TrimPrefix(line, "Gate:"))
|
|
} else if strings.HasPrefix(line, "Branch:") {
|
|
payload.Branch = strings.TrimSpace(strings.TrimPrefix(line, "Branch:"))
|
|
}
|
|
}
|
|
|
|
return payload, nil
|
|
}
|
|
|
|
// ParseHelp extracts payload from a HELP message.
|
|
// Subject format: HELP: <topic>
|
|
// Body format:
|
|
//
|
|
// Agent: <agent-id>
|
|
// Issue: <issue-id>
|
|
// Problem: <description>
|
|
// Tried: <what was attempted>
|
|
func ParseHelp(subject, body string) (*HelpPayload, error) {
|
|
matches := PatternHelp.FindStringSubmatch(subject)
|
|
if len(matches) < 2 {
|
|
return nil, fmt.Errorf("invalid HELP subject: %s", subject)
|
|
}
|
|
|
|
payload := &HelpPayload{
|
|
Topic: matches[1],
|
|
RequestedAt: time.Now(),
|
|
}
|
|
|
|
// Parse body for structured fields
|
|
for _, line := range strings.Split(body, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if strings.HasPrefix(line, "Agent:") {
|
|
payload.Agent = strings.TrimSpace(strings.TrimPrefix(line, "Agent:"))
|
|
} else if strings.HasPrefix(line, "Issue:") {
|
|
payload.IssueID = strings.TrimSpace(strings.TrimPrefix(line, "Issue:"))
|
|
} else if strings.HasPrefix(line, "Problem:") {
|
|
payload.Problem = strings.TrimSpace(strings.TrimPrefix(line, "Problem:"))
|
|
} else if strings.HasPrefix(line, "Tried:") {
|
|
payload.Tried = strings.TrimSpace(strings.TrimPrefix(line, "Tried:"))
|
|
}
|
|
}
|
|
|
|
return payload, nil
|
|
}
|
|
|
|
// ParseMerged extracts payload from a MERGED message.
|
|
// Subject format: MERGED <polecat-name>
|
|
// Body format:
|
|
//
|
|
// Branch: <branch>
|
|
// Issue: <issue-id>
|
|
// Merged-At: <timestamp>
|
|
func ParseMerged(subject, body string) (*MergedPayload, error) {
|
|
matches := PatternMerged.FindStringSubmatch(subject)
|
|
if len(matches) < 2 {
|
|
return nil, fmt.Errorf("invalid MERGED subject: %s", subject)
|
|
}
|
|
|
|
payload := &MergedPayload{
|
|
PolecatName: matches[1],
|
|
}
|
|
|
|
// Parse body for structured fields
|
|
for _, line := range strings.Split(body, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if strings.HasPrefix(line, "Branch:") {
|
|
payload.Branch = strings.TrimSpace(strings.TrimPrefix(line, "Branch:"))
|
|
} else if strings.HasPrefix(line, "Issue:") {
|
|
payload.IssueID = strings.TrimSpace(strings.TrimPrefix(line, "Issue:"))
|
|
} else if strings.HasPrefix(line, "Merged-At:") {
|
|
ts := strings.TrimSpace(strings.TrimPrefix(line, "Merged-At:"))
|
|
if t, err := time.Parse(time.RFC3339, ts); err == nil {
|
|
payload.MergedAt = t
|
|
}
|
|
}
|
|
}
|
|
|
|
return payload, nil
|
|
}
|
|
|
|
// ParseMergeFailed extracts payload from a MERGE_FAILED message.
|
|
// Subject format: MERGE_FAILED <polecat-name>
|
|
// Body format:
|
|
//
|
|
// Branch: <branch>
|
|
// Issue: <issue-id>
|
|
// FailureType: <type>
|
|
// Error: <error-message>
|
|
func ParseMergeFailed(subject, body string) (*MergeFailedPayload, error) {
|
|
matches := PatternMergeFailed.FindStringSubmatch(subject)
|
|
if len(matches) < 2 {
|
|
return nil, fmt.Errorf("invalid MERGE_FAILED subject: %s", subject)
|
|
}
|
|
|
|
payload := &MergeFailedPayload{
|
|
PolecatName: matches[1],
|
|
FailedAt: time.Now(),
|
|
}
|
|
|
|
// Parse body for structured fields
|
|
for _, line := range strings.Split(body, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
switch {
|
|
case strings.HasPrefix(line, "Branch:"):
|
|
payload.Branch = strings.TrimSpace(strings.TrimPrefix(line, "Branch:"))
|
|
case strings.HasPrefix(line, "Issue:"):
|
|
payload.IssueID = strings.TrimSpace(strings.TrimPrefix(line, "Issue:"))
|
|
case strings.HasPrefix(line, "FailureType:"):
|
|
payload.FailureType = strings.TrimSpace(strings.TrimPrefix(line, "FailureType:"))
|
|
case strings.HasPrefix(line, "Error:"):
|
|
payload.Error = strings.TrimSpace(strings.TrimPrefix(line, "Error:"))
|
|
}
|
|
}
|
|
|
|
return payload, nil
|
|
}
|
|
|
|
// ParseSwarmStart extracts payload from a SWARM_START message.
|
|
// Body format is JSON: {"swarm_id": "batch-123", "beads": ["bd-a", "bd-b"]}
|
|
func ParseSwarmStart(body string) (*SwarmStartPayload, error) {
|
|
payload := &SwarmStartPayload{
|
|
StartedAt: time.Now(),
|
|
}
|
|
|
|
// Parse the JSON-like body (simplified parsing for key-value extraction)
|
|
// Full JSON parsing would require encoding/json import
|
|
for _, line := range strings.Split(body, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if strings.HasPrefix(line, "SwarmID:") || strings.HasPrefix(line, "swarm_id:") {
|
|
payload.SwarmID = strings.TrimSpace(strings.TrimPrefix(strings.TrimPrefix(line, "SwarmID:"), "swarm_id:"))
|
|
} else if strings.HasPrefix(line, "Total:") {
|
|
_, _ = fmt.Sscanf(line, "Total: %d", &payload.Total)
|
|
}
|
|
}
|
|
|
|
return payload, nil
|
|
}
|
|
|
|
// CleanupWispLabels generates labels for a cleanup wisp.
|
|
func CleanupWispLabels(polecatName, state string) []string {
|
|
return []string{
|
|
"cleanup",
|
|
fmt.Sprintf("polecat:%s", polecatName),
|
|
fmt.Sprintf("state:%s", state),
|
|
}
|
|
}
|
|
|
|
// SwarmWispLabels generates labels for a swarm tracking wisp.
|
|
func SwarmWispLabels(swarmID string, total, completed int, startTime time.Time) []string {
|
|
return []string{
|
|
"swarm",
|
|
fmt.Sprintf("swarm_id:%s", swarmID),
|
|
fmt.Sprintf("total:%d", total),
|
|
fmt.Sprintf("completed:%d", completed),
|
|
fmt.Sprintf("start:%s", startTime.Format(time.RFC3339)),
|
|
}
|
|
}
|
|
|
|
// HelpAssessment represents the Witness's assessment of a help request.
|
|
type HelpAssessment struct {
|
|
CanHelp bool
|
|
HelpAction string // What the Witness can do to help
|
|
NeedsEscalation bool
|
|
EscalationReason string
|
|
}
|
|
|
|
// AssessHelpRequest provides guidance for the Witness to assess a help request.
|
|
// This is a template/guide - actual assessment is done by the Claude agent.
|
|
func AssessHelpRequest(payload *HelpPayload) *HelpAssessment {
|
|
assessment := &HelpAssessment{}
|
|
|
|
// Heuristics for common help requests that Witness can handle
|
|
topic := strings.ToLower(payload.Topic)
|
|
problem := strings.ToLower(payload.Problem)
|
|
|
|
// Git issues - Witness can often help
|
|
if strings.Contains(topic, "git") || strings.Contains(problem, "git") {
|
|
if strings.Contains(problem, "conflict") {
|
|
assessment.CanHelp = false
|
|
assessment.NeedsEscalation = true
|
|
assessment.EscalationReason = "Git conflicts require human review"
|
|
} else if strings.Contains(problem, "push") || strings.Contains(problem, "fetch") {
|
|
assessment.CanHelp = true
|
|
assessment.HelpAction = "Check git remote status and network connectivity"
|
|
}
|
|
}
|
|
|
|
// Test failures - usually need escalation
|
|
if strings.Contains(topic, "test") || strings.Contains(problem, "test fail") {
|
|
assessment.CanHelp = false
|
|
assessment.NeedsEscalation = true
|
|
assessment.EscalationReason = "Test failures require investigation"
|
|
}
|
|
|
|
// Build issues - Witness can check basics
|
|
if strings.Contains(topic, "build") || strings.Contains(problem, "compile") {
|
|
assessment.CanHelp = true
|
|
assessment.HelpAction = "Verify dependencies and build configuration"
|
|
}
|
|
|
|
// Requirements unclear - always escalate
|
|
if strings.Contains(topic, "unclear") || strings.Contains(problem, "requirement") ||
|
|
strings.Contains(problem, "don't understand") {
|
|
assessment.CanHelp = false
|
|
assessment.NeedsEscalation = true
|
|
assessment.EscalationReason = "Requirements clarification needed from Mayor"
|
|
}
|
|
|
|
// Default: escalate if we don't recognize the pattern
|
|
if !assessment.CanHelp && !assessment.NeedsEscalation {
|
|
assessment.NeedsEscalation = true
|
|
assessment.EscalationReason = "Unknown help request type"
|
|
}
|
|
|
|
return assessment
|
|
}
|