Implement Polecat-Witness protocol handlers (gt-m5w4g.3)
Add protocol message parsing and handlers for polecat→witness communication: - POLECAT_DONE: Parse completion messages, create cleanup wisps - HELP: Parse help requests, assess if Witness can help or escalate to Mayor - MERGED: Parse refinery merge confirmations - LIFECYCLE:Shutdown: Handle daemon-triggered shutdowns - SWARM_START: Parse batch work initialization Files added: - internal/witness/protocol.go: Message classification and parsing - internal/witness/handlers.go: Handler implementations with wisp/mail integration - internal/witness/protocol_test.go: Unit tests for all parsing functions 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
396
internal/witness/handlers.go
Normal file
396
internal/witness/handlers.go
Normal file
@@ -0,0 +1,396 @@
|
||||
package witness
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/steveyegge/gastown/internal/mail"
|
||||
)
|
||||
|
||||
// HandlerResult tracks the result of handling a protocol message.
|
||||
type HandlerResult struct {
|
||||
MessageID string
|
||||
ProtocolType ProtocolType
|
||||
Handled bool
|
||||
Action string
|
||||
WispCreated string // ID of created wisp (if any)
|
||||
MailSent string // ID of sent mail (if any)
|
||||
Error error
|
||||
}
|
||||
|
||||
// HandlePolecatDone processes a POLECAT_DONE message from a polecat.
|
||||
// Creates a cleanup wisp for the polecat to trigger the verification flow.
|
||||
func HandlePolecatDone(workDir, rigName string, msg *mail.Message) *HandlerResult {
|
||||
result := &HandlerResult{
|
||||
MessageID: msg.ID,
|
||||
ProtocolType: ProtoPolecatDone,
|
||||
}
|
||||
|
||||
// Parse the message
|
||||
payload, err := ParsePolecatDone(msg.Subject, msg.Body)
|
||||
if err != nil {
|
||||
result.Error = fmt.Errorf("parsing POLECAT_DONE: %w", err)
|
||||
return result
|
||||
}
|
||||
|
||||
// Create a cleanup wisp for this polecat
|
||||
wispID, err := createCleanupWisp(workDir, payload.PolecatName, payload.IssueID, payload.Branch)
|
||||
if err != nil {
|
||||
result.Error = fmt.Errorf("creating cleanup wisp: %w", err)
|
||||
return result
|
||||
}
|
||||
|
||||
result.Handled = true
|
||||
result.WispCreated = wispID
|
||||
result.Action = fmt.Sprintf("created cleanup wisp %s for polecat %s", wispID, payload.PolecatName)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// HandleLifecycleShutdown processes a LIFECYCLE:Shutdown message.
|
||||
// Similar to POLECAT_DONE but triggered by daemon rather than polecat.
|
||||
func HandleLifecycleShutdown(workDir, rigName string, msg *mail.Message) *HandlerResult {
|
||||
result := &HandlerResult{
|
||||
MessageID: msg.ID,
|
||||
ProtocolType: ProtoLifecycleShutdown,
|
||||
}
|
||||
|
||||
// Extract polecat name from subject
|
||||
matches := PatternLifecycleShutdown.FindStringSubmatch(msg.Subject)
|
||||
if len(matches) < 2 {
|
||||
result.Error = fmt.Errorf("invalid LIFECYCLE:Shutdown subject: %s", msg.Subject)
|
||||
return result
|
||||
}
|
||||
polecatName := matches[1]
|
||||
|
||||
// Create a cleanup wisp
|
||||
wispID, err := createCleanupWisp(workDir, polecatName, "", "")
|
||||
if err != nil {
|
||||
result.Error = fmt.Errorf("creating cleanup wisp: %w", err)
|
||||
return result
|
||||
}
|
||||
|
||||
result.Handled = true
|
||||
result.WispCreated = wispID
|
||||
result.Action = fmt.Sprintf("created cleanup wisp %s for shutdown %s", wispID, polecatName)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// HandleHelp processes a HELP message from a polecat requesting intervention.
|
||||
// Assesses the request and either helps directly or escalates to Mayor.
|
||||
func HandleHelp(workDir, rigName string, msg *mail.Message, router *mail.Router) *HandlerResult {
|
||||
result := &HandlerResult{
|
||||
MessageID: msg.ID,
|
||||
ProtocolType: ProtoHelp,
|
||||
}
|
||||
|
||||
// Parse the message
|
||||
payload, err := ParseHelp(msg.Subject, msg.Body)
|
||||
if err != nil {
|
||||
result.Error = fmt.Errorf("parsing HELP: %w", err)
|
||||
return result
|
||||
}
|
||||
|
||||
// Assess the help request
|
||||
assessment := AssessHelpRequest(payload)
|
||||
|
||||
if assessment.CanHelp {
|
||||
// Log that we can help - actual help is done by the Claude agent
|
||||
result.Handled = true
|
||||
result.Action = fmt.Sprintf("can help with '%s': %s", payload.Topic, assessment.HelpAction)
|
||||
return result
|
||||
}
|
||||
|
||||
// Need to escalate to Mayor
|
||||
if assessment.NeedsEscalation {
|
||||
mailID, err := escalateToMayor(router, rigName, payload, assessment.EscalationReason)
|
||||
if err != nil {
|
||||
result.Error = fmt.Errorf("escalating to mayor: %w", err)
|
||||
return result
|
||||
}
|
||||
|
||||
result.Handled = true
|
||||
result.MailSent = mailID
|
||||
result.Action = fmt.Sprintf("escalated '%s' to mayor: %s", payload.Topic, assessment.EscalationReason)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// HandleMerged processes a MERGED message from the Refinery.
|
||||
// Finds the cleanup wisp for this polecat and triggers the nuke.
|
||||
func HandleMerged(workDir, rigName string, msg *mail.Message) *HandlerResult {
|
||||
result := &HandlerResult{
|
||||
MessageID: msg.ID,
|
||||
ProtocolType: ProtoMerged,
|
||||
}
|
||||
|
||||
// Parse the message
|
||||
payload, err := ParseMerged(msg.Subject, msg.Body)
|
||||
if err != nil {
|
||||
result.Error = fmt.Errorf("parsing MERGED: %w", err)
|
||||
return result
|
||||
}
|
||||
|
||||
// Find the cleanup wisp for this polecat
|
||||
wispID, err := findCleanupWisp(workDir, payload.PolecatName)
|
||||
if err != nil {
|
||||
result.Error = fmt.Errorf("finding cleanup wisp: %w", err)
|
||||
return result
|
||||
}
|
||||
|
||||
if wispID == "" {
|
||||
// No wisp found - polecat may have been cleaned up already
|
||||
result.Handled = true
|
||||
result.Action = fmt.Sprintf("no cleanup wisp found for %s (may be already cleaned)", payload.PolecatName)
|
||||
return result
|
||||
}
|
||||
|
||||
result.Handled = true
|
||||
result.WispCreated = wispID // Reference to existing wisp
|
||||
result.Action = fmt.Sprintf("found cleanup wisp %s for %s, ready to nuke", wispID, payload.PolecatName)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// HandleSwarmStart processes a SWARM_START message from the Mayor.
|
||||
// Creates a swarm tracking wisp to monitor batch polecat work.
|
||||
func HandleSwarmStart(workDir string, msg *mail.Message) *HandlerResult {
|
||||
result := &HandlerResult{
|
||||
MessageID: msg.ID,
|
||||
ProtocolType: ProtoSwarmStart,
|
||||
}
|
||||
|
||||
// Parse the message
|
||||
payload, err := ParseSwarmStart(msg.Body)
|
||||
if err != nil {
|
||||
result.Error = fmt.Errorf("parsing SWARM_START: %w", err)
|
||||
return result
|
||||
}
|
||||
|
||||
// Create a swarm tracking wisp
|
||||
wispID, err := createSwarmWisp(workDir, payload)
|
||||
if err != nil {
|
||||
result.Error = fmt.Errorf("creating swarm wisp: %w", err)
|
||||
return result
|
||||
}
|
||||
|
||||
result.Handled = true
|
||||
result.WispCreated = wispID
|
||||
result.Action = fmt.Sprintf("created swarm tracking wisp %s for %s", wispID, payload.SwarmID)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// createCleanupWisp creates a wisp to track polecat cleanup.
|
||||
func createCleanupWisp(workDir, polecatName, issueID, branch string) (string, error) {
|
||||
title := fmt.Sprintf("cleanup:%s", polecatName)
|
||||
description := fmt.Sprintf("Verify and cleanup polecat %s", polecatName)
|
||||
if issueID != "" {
|
||||
description += fmt.Sprintf("\nIssue: %s", issueID)
|
||||
}
|
||||
if branch != "" {
|
||||
description += fmt.Sprintf("\nBranch: %s", branch)
|
||||
}
|
||||
|
||||
labels := strings.Join(CleanupWispLabels(polecatName, "pending"), ",")
|
||||
|
||||
cmd := exec.Command("bd", "create",
|
||||
"--wisp",
|
||||
"--title", title,
|
||||
"--description", description,
|
||||
"--labels", labels,
|
||||
)
|
||||
cmd.Dir = workDir
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
errMsg := strings.TrimSpace(stderr.String())
|
||||
if errMsg != "" {
|
||||
return "", fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Extract wisp ID from output (bd create outputs "Created: <id>")
|
||||
output := strings.TrimSpace(stdout.String())
|
||||
if strings.HasPrefix(output, "Created:") {
|
||||
return strings.TrimSpace(strings.TrimPrefix(output, "Created:")), nil
|
||||
}
|
||||
|
||||
// Try to extract ID from output
|
||||
for _, line := range strings.Split(output, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
// Look for bead ID pattern (e.g., "gt-abc123")
|
||||
if strings.Contains(line, "-") && len(line) < 20 {
|
||||
return line, nil
|
||||
}
|
||||
}
|
||||
|
||||
return output, nil
|
||||
}
|
||||
|
||||
// createSwarmWisp creates a wisp to track swarm (batch) work.
|
||||
func createSwarmWisp(workDir string, payload *SwarmStartPayload) (string, error) {
|
||||
title := fmt.Sprintf("swarm:%s", payload.SwarmID)
|
||||
description := fmt.Sprintf("Tracking batch: %s\nTotal: %d polecats", payload.SwarmID, payload.Total)
|
||||
|
||||
labels := strings.Join(SwarmWispLabels(payload.SwarmID, payload.Total, 0, payload.StartedAt), ",")
|
||||
|
||||
cmd := exec.Command("bd", "create",
|
||||
"--wisp",
|
||||
"--title", title,
|
||||
"--description", description,
|
||||
"--labels", labels,
|
||||
)
|
||||
cmd.Dir = workDir
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
errMsg := strings.TrimSpace(stderr.String())
|
||||
if errMsg != "" {
|
||||
return "", fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
|
||||
output := strings.TrimSpace(stdout.String())
|
||||
if strings.HasPrefix(output, "Created:") {
|
||||
return strings.TrimSpace(strings.TrimPrefix(output, "Created:")), nil
|
||||
}
|
||||
|
||||
return output, nil
|
||||
}
|
||||
|
||||
// findCleanupWisp finds an existing cleanup wisp for a polecat.
|
||||
func findCleanupWisp(workDir, polecatName string) (string, error) {
|
||||
cmd := exec.Command("bd", "list",
|
||||
"--wisp",
|
||||
"--labels", fmt.Sprintf("polecat:%s,state:merge-requested", polecatName),
|
||||
"--status", "open",
|
||||
"--json",
|
||||
)
|
||||
cmd.Dir = workDir
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
// Empty result is fine
|
||||
if strings.Contains(stderr.String(), "no issues found") {
|
||||
return "", nil
|
||||
}
|
||||
errMsg := strings.TrimSpace(stderr.String())
|
||||
if errMsg != "" {
|
||||
return "", fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Parse JSON to get the wisp ID
|
||||
output := strings.TrimSpace(stdout.String())
|
||||
if output == "" || output == "[]" || output == "null" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// Simple extraction - look for "id" field
|
||||
// Full JSON parsing would add dependency on encoding/json
|
||||
if idx := strings.Index(output, `"id":`); idx >= 0 {
|
||||
rest := output[idx+5:]
|
||||
rest = strings.TrimLeft(rest, ` "`)
|
||||
if endIdx := strings.IndexAny(rest, `",}`); endIdx > 0 {
|
||||
return rest[:endIdx], nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// escalateToMayor sends an escalation mail to the Mayor.
|
||||
func escalateToMayor(router *mail.Router, rigName string, payload *HelpPayload, reason string) (string, error) {
|
||||
msg := &mail.Message{
|
||||
From: fmt.Sprintf("%s/witness", rigName),
|
||||
To: "mayor/",
|
||||
Subject: fmt.Sprintf("Escalation: %s needs help", payload.Agent),
|
||||
Priority: mail.PriorityHigh,
|
||||
Body: fmt.Sprintf(`Agent: %s
|
||||
Issue: %s
|
||||
Topic: %s
|
||||
Problem: %s
|
||||
Tried: %s
|
||||
Escalation reason: %s
|
||||
Requested at: %s`,
|
||||
payload.Agent,
|
||||
payload.IssueID,
|
||||
payload.Topic,
|
||||
payload.Problem,
|
||||
payload.Tried,
|
||||
reason,
|
||||
payload.RequestedAt.Format(time.RFC3339),
|
||||
),
|
||||
}
|
||||
|
||||
if err := router.Send(msg); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return msg.ID, nil
|
||||
}
|
||||
|
||||
// UpdateCleanupWispState updates a cleanup wisp's state label.
|
||||
func UpdateCleanupWispState(workDir, wispID, newState string) error {
|
||||
// Get current labels to preserve other labels
|
||||
cmd := exec.Command("bd", "show", wispID, "--json")
|
||||
cmd.Dir = workDir
|
||||
|
||||
var stdout bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("getting wisp: %w", err)
|
||||
}
|
||||
|
||||
// Extract polecat name from existing labels for the update
|
||||
output := stdout.String()
|
||||
var polecatName string
|
||||
if idx := strings.Index(output, `polecat:`); idx >= 0 {
|
||||
rest := output[idx+8:]
|
||||
if endIdx := strings.IndexAny(rest, `",]}`); endIdx > 0 {
|
||||
polecatName = rest[:endIdx]
|
||||
}
|
||||
}
|
||||
|
||||
if polecatName == "" {
|
||||
polecatName = "unknown"
|
||||
}
|
||||
|
||||
// Update with new state
|
||||
newLabels := strings.Join(CleanupWispLabels(polecatName, newState), ",")
|
||||
|
||||
updateCmd := exec.Command("bd", "update", wispID, "--labels", newLabels)
|
||||
updateCmd.Dir = workDir
|
||||
|
||||
var stderr bytes.Buffer
|
||||
updateCmd.Stderr = &stderr
|
||||
|
||||
if err := updateCmd.Run(); err != nil {
|
||||
errMsg := strings.TrimSpace(stderr.String())
|
||||
if errMsg != "" {
|
||||
return fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
304
internal/witness/protocol.go
Normal file
304
internal/witness/protocol.go
Normal file
@@ -0,0 +1,304 @@
|
||||
// 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+)`)
|
||||
|
||||
// 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"
|
||||
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 // MERGED, ESCALATED, DEFERRED
|
||||
IssueID string
|
||||
MRID string
|
||||
Branch string
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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 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: MERGED|ESCALATED|DEFERRED
|
||||
// Issue: <issue-id>
|
||||
// MR: <mr-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, "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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
250
internal/witness/protocol_test.go
Normal file
250
internal/witness/protocol_test.go
Normal file
@@ -0,0 +1,250 @@
|
||||
package witness
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestClassifyMessage(t *testing.T) {
|
||||
tests := []struct {
|
||||
subject string
|
||||
expected ProtocolType
|
||||
}{
|
||||
{"POLECAT_DONE nux", ProtoPolecatDone},
|
||||
{"POLECAT_DONE ace", ProtoPolecatDone},
|
||||
{"LIFECYCLE:Shutdown nux", ProtoLifecycleShutdown},
|
||||
{"HELP: Tests failing", ProtoHelp},
|
||||
{"HELP: Git conflict", ProtoHelp},
|
||||
{"MERGED nux", ProtoMerged},
|
||||
{"MERGED valkyrie", ProtoMerged},
|
||||
{"🤝 HANDOFF: Patrol context", ProtoHandoff},
|
||||
{"🤝HANDOFF: No space", ProtoHandoff},
|
||||
{"SWARM_START", ProtoSwarmStart},
|
||||
{"Unknown message", ProtoUnknown},
|
||||
{"", ProtoUnknown},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.subject, func(t *testing.T) {
|
||||
result := ClassifyMessage(tc.subject)
|
||||
if result != tc.expected {
|
||||
t.Errorf("ClassifyMessage(%q) = %v, want %v", tc.subject, result, tc.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePolecatDone(t *testing.T) {
|
||||
subject := "POLECAT_DONE nux"
|
||||
body := `Exit: MERGED
|
||||
Issue: gt-abc123
|
||||
MR: gt-mr-xyz
|
||||
Branch: feature-branch`
|
||||
|
||||
payload, err := ParsePolecatDone(subject, body)
|
||||
if err != nil {
|
||||
t.Fatalf("ParsePolecatDone() error = %v", err)
|
||||
}
|
||||
|
||||
if payload.PolecatName != "nux" {
|
||||
t.Errorf("PolecatName = %q, want %q", payload.PolecatName, "nux")
|
||||
}
|
||||
if payload.Exit != "MERGED" {
|
||||
t.Errorf("Exit = %q, want %q", payload.Exit, "MERGED")
|
||||
}
|
||||
if payload.IssueID != "gt-abc123" {
|
||||
t.Errorf("IssueID = %q, want %q", payload.IssueID, "gt-abc123")
|
||||
}
|
||||
if payload.MRID != "gt-mr-xyz" {
|
||||
t.Errorf("MRID = %q, want %q", payload.MRID, "gt-mr-xyz")
|
||||
}
|
||||
if payload.Branch != "feature-branch" {
|
||||
t.Errorf("Branch = %q, want %q", payload.Branch, "feature-branch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePolecatDone_MinimalBody(t *testing.T) {
|
||||
subject := "POLECAT_DONE ace"
|
||||
body := "Exit: DEFERRED"
|
||||
|
||||
payload, err := ParsePolecatDone(subject, body)
|
||||
if err != nil {
|
||||
t.Fatalf("ParsePolecatDone() error = %v", err)
|
||||
}
|
||||
|
||||
if payload.PolecatName != "ace" {
|
||||
t.Errorf("PolecatName = %q, want %q", payload.PolecatName, "ace")
|
||||
}
|
||||
if payload.Exit != "DEFERRED" {
|
||||
t.Errorf("Exit = %q, want %q", payload.Exit, "DEFERRED")
|
||||
}
|
||||
if payload.IssueID != "" {
|
||||
t.Errorf("IssueID = %q, want empty", payload.IssueID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePolecatDone_InvalidSubject(t *testing.T) {
|
||||
_, err := ParsePolecatDone("Invalid subject", "body")
|
||||
if err == nil {
|
||||
t.Error("ParsePolecatDone() expected error for invalid subject")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHelp(t *testing.T) {
|
||||
subject := "HELP: Tests failing on CI"
|
||||
body := `Agent: gastown/polecats/nux
|
||||
Issue: gt-abc123
|
||||
Problem: Unit tests timeout after 30 seconds
|
||||
Tried: Increased timeout, checked for deadlocks`
|
||||
|
||||
payload, err := ParseHelp(subject, body)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseHelp() error = %v", err)
|
||||
}
|
||||
|
||||
if payload.Topic != "Tests failing on CI" {
|
||||
t.Errorf("Topic = %q, want %q", payload.Topic, "Tests failing on CI")
|
||||
}
|
||||
if payload.Agent != "gastown/polecats/nux" {
|
||||
t.Errorf("Agent = %q, want %q", payload.Agent, "gastown/polecats/nux")
|
||||
}
|
||||
if payload.IssueID != "gt-abc123" {
|
||||
t.Errorf("IssueID = %q, want %q", payload.IssueID, "gt-abc123")
|
||||
}
|
||||
if payload.Problem != "Unit tests timeout after 30 seconds" {
|
||||
t.Errorf("Problem = %q, want %q", payload.Problem, "Unit tests timeout after 30 seconds")
|
||||
}
|
||||
if payload.Tried != "Increased timeout, checked for deadlocks" {
|
||||
t.Errorf("Tried = %q, want %q", payload.Tried, "Increased timeout, checked for deadlocks")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHelp_InvalidSubject(t *testing.T) {
|
||||
_, err := ParseHelp("Not a help message", "body")
|
||||
if err == nil {
|
||||
t.Error("ParseHelp() expected error for invalid subject")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMerged(t *testing.T) {
|
||||
subject := "MERGED nux"
|
||||
body := `Branch: feature-nux
|
||||
Issue: gt-abc123
|
||||
Merged-At: 2025-12-30T10:30:00Z`
|
||||
|
||||
payload, err := ParseMerged(subject, body)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseMerged() error = %v", err)
|
||||
}
|
||||
|
||||
if payload.PolecatName != "nux" {
|
||||
t.Errorf("PolecatName = %q, want %q", payload.PolecatName, "nux")
|
||||
}
|
||||
if payload.Branch != "feature-nux" {
|
||||
t.Errorf("Branch = %q, want %q", payload.Branch, "feature-nux")
|
||||
}
|
||||
if payload.IssueID != "gt-abc123" {
|
||||
t.Errorf("IssueID = %q, want %q", payload.IssueID, "gt-abc123")
|
||||
}
|
||||
if payload.MergedAt.IsZero() {
|
||||
t.Error("MergedAt should not be zero")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMerged_InvalidSubject(t *testing.T) {
|
||||
_, err := ParseMerged("Not merged", "body")
|
||||
if err == nil {
|
||||
t.Error("ParseMerged() expected error for invalid subject")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupWispLabels(t *testing.T) {
|
||||
labels := CleanupWispLabels("nux", "pending")
|
||||
|
||||
expected := []string{"cleanup", "polecat:nux", "state:pending"}
|
||||
if len(labels) != len(expected) {
|
||||
t.Fatalf("CleanupWispLabels() returned %d labels, want %d", len(labels), len(expected))
|
||||
}
|
||||
|
||||
for i, label := range labels {
|
||||
if label != expected[i] {
|
||||
t.Errorf("labels[%d] = %q, want %q", i, label, expected[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssessHelpRequest_GitConflict(t *testing.T) {
|
||||
payload := &HelpPayload{
|
||||
Topic: "Git issue",
|
||||
Problem: "Merge conflict in main.go",
|
||||
}
|
||||
|
||||
assessment := AssessHelpRequest(payload)
|
||||
|
||||
if assessment.CanHelp {
|
||||
t.Error("Should not be able to help with git conflicts")
|
||||
}
|
||||
if !assessment.NeedsEscalation {
|
||||
t.Error("Git conflicts should need escalation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssessHelpRequest_GitPush(t *testing.T) {
|
||||
payload := &HelpPayload{
|
||||
Topic: "Git push failing",
|
||||
Problem: "Cannot push to remote",
|
||||
}
|
||||
|
||||
assessment := AssessHelpRequest(payload)
|
||||
|
||||
if !assessment.CanHelp {
|
||||
t.Error("Should be able to help with git push issues")
|
||||
}
|
||||
if assessment.HelpAction == "" {
|
||||
t.Error("HelpAction should not be empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssessHelpRequest_TestFailures(t *testing.T) {
|
||||
payload := &HelpPayload{
|
||||
Topic: "Test failures",
|
||||
Problem: "Tests fail on CI",
|
||||
}
|
||||
|
||||
assessment := AssessHelpRequest(payload)
|
||||
|
||||
if assessment.CanHelp {
|
||||
t.Error("Should not be able to help with test failures")
|
||||
}
|
||||
if !assessment.NeedsEscalation {
|
||||
t.Error("Test failures should need escalation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssessHelpRequest_RequirementsUnclear(t *testing.T) {
|
||||
payload := &HelpPayload{
|
||||
Topic: "Requirements unclear",
|
||||
Problem: "Don't understand the requirements for this task",
|
||||
}
|
||||
|
||||
assessment := AssessHelpRequest(payload)
|
||||
|
||||
if assessment.CanHelp {
|
||||
t.Error("Should not be able to help with unclear requirements")
|
||||
}
|
||||
if !assessment.NeedsEscalation {
|
||||
t.Error("Unclear requirements should need escalation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssessHelpRequest_BuildIssues(t *testing.T) {
|
||||
payload := &HelpPayload{
|
||||
Topic: "Build failing",
|
||||
Problem: "Cannot compile the project",
|
||||
}
|
||||
|
||||
assessment := AssessHelpRequest(payload)
|
||||
|
||||
if !assessment.CanHelp {
|
||||
t.Error("Should be able to help with build issues")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user