fix(refinery): Send MERGE_FAILED to Witness when merge is rejected

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>
This commit is contained in:
markov-kernel
2026-01-05 14:59:30 +01:00
committed by Steve Yegge
parent 9cb14cc41a
commit 6fe25c757c
4 changed files with 183 additions and 0 deletions

View File

@@ -16,7 +16,9 @@ import (
"github.com/steveyegge/gastown/internal/beads"
"github.com/steveyegge/gastown/internal/git"
"github.com/steveyegge/gastown/internal/mail"
"github.com/steveyegge/gastown/internal/mrqueue"
"github.com/steveyegge/gastown/internal/protocol"
"github.com/steveyegge/gastown/internal/rig"
)
@@ -80,6 +82,7 @@ type Engineer struct {
workDir string
output io.Writer // Output destination for user-facing messages
eventLogger *mrqueue.EventLogger
router *mail.Router // Mail router for sending protocol messages
// stopCh is used for graceful shutdown
stopCh chan struct{}
@@ -100,6 +103,7 @@ func NewEngineer(r *rig.Rig) *Engineer {
workDir: r.Path,
output: os.Stdout,
eventLogger: mrqueue.NewEventLoggerFromRig(r.Path),
router: mail.NewRouter(r.Path),
stopCh: make(chan struct{}),
}
}
@@ -562,6 +566,21 @@ func (e *Engineer) handleFailureFromQueue(mr *mrqueue.MR, result ProcessResult)
_, _ = fmt.Fprintf(e.output, "[Engineer] Warning: failed to log merge_failed event: %v\n", err)
}
// Notify Witness of the failure so polecat can be alerted
// Determine failure type from result
failureType := "build"
if result.Conflict {
failureType = "conflict"
} else if result.TestsFailed {
failureType = "tests"
}
msg := protocol.NewMergeFailedMessage(e.rig.Name, mr.Worker, mr.Branch, mr.SourceIssue, mr.Target, failureType, result.Error)
if err := e.router.Send(msg); err != nil {
fmt.Fprintf(e.output, "[Engineer] Warning: failed to send MERGE_FAILED to witness: %v\n", err)
} else {
fmt.Fprintf(e.output, "[Engineer] Notified witness of merge failure for %s\n", mr.Worker)
}
// If this was a conflict, create a conflict-resolution task for dispatch
// and block the MR until the task is resolved (non-blocking delegation)
if result.Conflict {

View File

@@ -294,6 +294,56 @@ func HandleMerged(workDir, rigName string, msg *mail.Message) *HandlerResult {
return result
}
// HandleMergeFailed processes a MERGE_FAILED message from the Refinery.
// Notifies the polecat that their merge was rejected and rework is needed.
func HandleMergeFailed(workDir, rigName string, msg *mail.Message, router *mail.Router) *HandlerResult {
result := &HandlerResult{
MessageID: msg.ID,
ProtocolType: ProtoMergeFailed,
}
// Parse the message
payload, err := ParseMergeFailed(msg.Subject, msg.Body)
if err != nil {
result.Error = fmt.Errorf("parsing MERGE_FAILED: %w", err)
return result
}
// Notify the polecat about the failure
polecatAddr := fmt.Sprintf("%s/polecats/%s", rigName, payload.PolecatName)
notification := &mail.Message{
From: fmt.Sprintf("%s/witness", rigName),
To: polecatAddr,
Subject: fmt.Sprintf("Merge failed: %s", payload.FailureType),
Priority: mail.PriorityHigh,
Type: mail.TypeTask,
Body: fmt.Sprintf(`Your merge request was rejected.
Branch: %s
Issue: %s
Failure: %s
Error: %s
Please fix the issue and resubmit with 'gt done'.`,
payload.Branch,
payload.IssueID,
payload.FailureType,
payload.Error,
),
}
if err := router.Send(notification); err != nil {
result.Error = fmt.Errorf("sending failure notification: %w", err)
return result
}
result.Handled = true
result.MailSent = notification.ID
result.Action = fmt.Sprintf("notified %s of merge failure: %s - %s", payload.PolecatName, payload.FailureType, payload.Error)
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 {

View File

@@ -22,6 +22,9 @@ var (
// 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`)
@@ -37,6 +40,7 @@ const (
ProtoLifecycleShutdown ProtocolType = "lifecycle_shutdown"
ProtoHelp ProtocolType = "help"
ProtoMerged ProtocolType = "merged"
ProtoMergeFailed ProtocolType = "merge_failed"
ProtoHandoff ProtocolType = "handoff"
ProtoSwarmStart ProtocolType = "swarm_start"
ProtoUnknown ProtocolType = "unknown"
@@ -70,6 +74,16 @@ type MergedPayload struct {
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
@@ -89,6 +103,8 @@ func ClassifyMessage(subject string) ProtocolType {
return ProtoHelp
case PatternMerged.MatchString(subject):
return ProtoMerged
case PatternMergeFailed.MatchString(subject):
return ProtoMergeFailed
case PatternHandoff.MatchString(subject):
return ProtoHandoff
case PatternSwarmStart.MatchString(subject):
@@ -207,6 +223,43 @@ func ParseMerged(subject, body string) (*MergedPayload, error) {
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) {

View File

@@ -16,6 +16,8 @@ func TestClassifyMessage(t *testing.T) {
{"HELP: Git conflict", ProtoHelp},
{"MERGED nux", ProtoMerged},
{"MERGED valkyrie", ProtoMerged},
{"MERGE_FAILED nux", ProtoMergeFailed},
{"MERGE_FAILED ace", ProtoMergeFailed},
{"🤝 HANDOFF: Patrol context", ProtoHandoff},
{"🤝HANDOFF: No space", ProtoHandoff},
{"SWARM_START", ProtoSwarmStart},
@@ -157,6 +159,65 @@ func TestParseMerged_InvalidSubject(t *testing.T) {
}
}
func TestParseMergeFailed(t *testing.T) {
subject := "MERGE_FAILED nux"
body := `Branch: feature-nux
Issue: gt-abc123
FailureType: tests
Error: unit tests failed with 3 errors`
payload, err := ParseMergeFailed(subject, body)
if err != nil {
t.Fatalf("ParseMergeFailed() 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.FailureType != "tests" {
t.Errorf("FailureType = %q, want %q", payload.FailureType, "tests")
}
if payload.Error != "unit tests failed with 3 errors" {
t.Errorf("Error = %q, want %q", payload.Error, "unit tests failed with 3 errors")
}
if payload.FailedAt.IsZero() {
t.Error("FailedAt should not be zero")
}
}
func TestParseMergeFailed_MinimalBody(t *testing.T) {
subject := "MERGE_FAILED ace"
body := "FailureType: build"
payload, err := ParseMergeFailed(subject, body)
if err != nil {
t.Fatalf("ParseMergeFailed() error = %v", err)
}
if payload.PolecatName != "ace" {
t.Errorf("PolecatName = %q, want %q", payload.PolecatName, "ace")
}
if payload.FailureType != "build" {
t.Errorf("FailureType = %q, want %q", payload.FailureType, "build")
}
if payload.Branch != "" {
t.Errorf("Branch = %q, want empty", payload.Branch)
}
}
func TestParseMergeFailed_InvalidSubject(t *testing.T) {
_, err := ParseMergeFailed("Not a merge failed", "body")
if err == nil {
t.Error("ParseMergeFailed() expected error for invalid subject")
}
}
func TestCleanupWispLabels(t *testing.T) {
labels := CleanupWispLabels("nux", "pending")