fix: add gt witness process command to invoke polecat cleanup handlers (gt-h3gzj)

The Witness handlers (HandlePolecatDone, HandleMerged, etc.) existed in Go
code but were never called - there was no CLI command to invoke them.

This caused polecats to remain in 'done' state after MR merge because
POLECAT_DONE messages were never processed.

Changes:
- Add `gt witness process <rig>` command to process Witness mail
- Fix --wisp flag to --ephemeral in cleanup wisp creation
- Command processes POLECAT_DONE, MERGED, HELP, SWARM_START messages
- Auto-nukes clean polecats, creates cleanup wisps for dirty ones

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
gastown/crew/jack
2026-01-06 13:21:55 -08:00
committed by Steve Yegge
parent 74409dc32b
commit a3bccc881b
2 changed files with 228 additions and 5 deletions

View File

@@ -12,6 +12,7 @@ import (
"github.com/steveyegge/gastown/internal/claude"
"github.com/steveyegge/gastown/internal/config"
"github.com/steveyegge/gastown/internal/constants"
"github.com/steveyegge/gastown/internal/mail"
"github.com/steveyegge/gastown/internal/rig"
"github.com/steveyegge/gastown/internal/session"
"github.com/steveyegge/gastown/internal/style"
@@ -22,8 +23,9 @@ import (
// Witness command flags
var (
witnessForeground bool
witnessStatusJSON bool
witnessForeground bool
witnessStatusJSON bool
witnessProcessJSON bool
)
var witnessCmd = &cobra.Command{
@@ -105,6 +107,30 @@ Examples:
RunE: runWitnessRestart,
}
var witnessProcessCmd = &cobra.Command{
Use: "process <rig>",
Short: "Process witness mail",
Long: `Process protocol messages in the Witness's mailbox.
Reads unread messages and handles each based on protocol type:
POLECAT_DONE - Auto-nuke if clean, create cleanup wisp if dirty
LIFECYCLE:Shutdown - Auto-nuke if clean
MERGED - Verify and complete cleanup
MERGE_FAILED - Notify polecat of failure
HELP - Assess and escalate if needed
SWARM_START - Initialize swarm tracking
This command invokes the Go handlers that perform the actual cleanup
operations (killing tmux sessions, removing worktrees, etc.).
Examples:
gt witness process gastown
gt witness process gastown --json`,
Args: cobra.ExactArgs(1),
RunE: runWitnessProcess,
}
func init() {
// Start flags
witnessStartCmd.Flags().BoolVar(&witnessForeground, "foreground", false, "Run in foreground (default: background)")
@@ -112,12 +138,16 @@ func init() {
// Status flags
witnessStatusCmd.Flags().BoolVar(&witnessStatusJSON, "json", false, "Output as JSON")
// Process flags
witnessProcessCmd.Flags().BoolVar(&witnessProcessJSON, "json", false, "Output as JSON")
// Add subcommands
witnessCmd.AddCommand(witnessStartCmd)
witnessCmd.AddCommand(witnessStopCmd)
witnessCmd.AddCommand(witnessRestartCmd)
witnessCmd.AddCommand(witnessStatusCmd)
witnessCmd.AddCommand(witnessAttachCmd)
witnessCmd.AddCommand(witnessProcessCmd)
rootCmd.AddCommand(witnessCmd)
}
@@ -458,3 +488,196 @@ func runWitnessRestart(cmd *cobra.Command, args []string) error {
fmt.Printf(" %s\n", style.Dim.Render("Use 'gt witness attach' to connect"))
return nil
}
// WitnessProcessResult tracks the result of processing witness mail.
type WitnessProcessResult struct {
MessageID string `json:"message_id"`
ProtocolType witness.ProtocolType `json:"protocol_type"`
From string `json:"from"`
Subject string `json:"subject"`
Handled bool `json:"handled"`
Action string `json:"action"`
WispCreated string `json:"wisp_created,omitempty"`
Error string `json:"error,omitempty"`
}
func runWitnessProcess(cmd *cobra.Command, args []string) error {
rigName := args[0]
townRoot, err := workspace.FindFromCwdOrError()
if err != nil {
return fmt.Errorf("not in a Gas Town workspace: %w", err)
}
// Verify rig exists
_, r, err := getWitnessManager(rigName)
if err != nil {
return err
}
// Get witness mailbox
witnessAddr := fmt.Sprintf("%s/witness", rigName)
router := mail.NewRouter(townRoot)
mailbox, err := router.GetMailbox(witnessAddr)
if err != nil {
return fmt.Errorf("getting witness mailbox: %w", err)
}
// Get unread messages
messages, err := mailbox.ListUnread()
if err != nil {
return fmt.Errorf("listing unread messages: %w", err)
}
if len(messages) == 0 {
if witnessProcessJSON {
fmt.Println("[]")
} else {
fmt.Printf("%s No pending messages\n", style.Dim.Render("○"))
}
return nil
}
if !witnessProcessJSON {
fmt.Printf("%s Processing %d message(s) for %s\n", style.Bold.Render("●"), len(messages), rigName)
}
var results []WitnessProcessResult
for _, msg := range messages {
result := processWitnessMessage(townRoot, r.Path, rigName, msg, router)
results = append(results, result)
if !witnessProcessJSON {
// Print result
if result.Error != "" {
fmt.Printf(" %s [%s] %s: %s\n",
style.Error.Render("✗"),
result.ProtocolType,
msg.Subject,
result.Error)
} else if result.Handled {
fmt.Printf(" %s [%s] %s\n",
style.Bold.Render("✓"),
result.ProtocolType,
result.Action)
} else {
fmt.Printf(" %s [%s] %s\n",
style.Dim.Render("○"),
result.ProtocolType,
result.Action)
}
}
// Archive handled messages
if result.Handled && result.Error == "" {
_ = mailbox.Delete(msg.ID)
}
}
// Output
if witnessProcessJSON {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(results)
}
// Summary
handled := 0
errors := 0
for _, r := range results {
if r.Handled {
handled++
}
if r.Error != "" {
errors++
}
}
fmt.Println()
fmt.Printf("%s Processed %d/%d messages",
style.Bold.Render("✓"), handled, len(results))
if errors > 0 {
fmt.Printf(" (%d errors)", errors)
}
fmt.Println()
return nil
}
// processWitnessMessage handles a single protocol message and returns the result.
func processWitnessMessage(townRoot, rigPath, rigName string, msg *mail.Message, router *mail.Router) WitnessProcessResult {
result := WitnessProcessResult{
MessageID: msg.ID,
From: msg.From,
Subject: msg.Subject,
}
// Classify the message
result.ProtocolType = witness.ClassifyMessage(msg.Subject)
// Handle based on type
switch result.ProtocolType {
case witness.ProtoPolecatDone:
handlerResult := witness.HandlePolecatDone(rigPath, rigName, msg)
result.Handled = handlerResult.Handled
result.Action = handlerResult.Action
result.WispCreated = handlerResult.WispCreated
if handlerResult.Error != nil {
result.Error = handlerResult.Error.Error()
}
case witness.ProtoLifecycleShutdown:
handlerResult := witness.HandleLifecycleShutdown(rigPath, rigName, msg)
result.Handled = handlerResult.Handled
result.Action = handlerResult.Action
result.WispCreated = handlerResult.WispCreated
if handlerResult.Error != nil {
result.Error = handlerResult.Error.Error()
}
case witness.ProtoMerged:
handlerResult := witness.HandleMerged(rigPath, rigName, msg)
result.Handled = handlerResult.Handled
result.Action = handlerResult.Action
result.WispCreated = handlerResult.WispCreated
if handlerResult.Error != nil {
result.Error = handlerResult.Error.Error()
}
case witness.ProtoMergeFailed:
handlerResult := witness.HandleMergeFailed(rigPath, rigName, msg, router)
result.Handled = handlerResult.Handled
result.Action = handlerResult.Action
if handlerResult.Error != nil {
result.Error = handlerResult.Error.Error()
}
case witness.ProtoHelp:
handlerResult := witness.HandleHelp(rigPath, rigName, msg, router)
result.Handled = handlerResult.Handled
result.Action = handlerResult.Action
if handlerResult.Error != nil {
result.Error = handlerResult.Error.Error()
}
case witness.ProtoSwarmStart:
handlerResult := witness.HandleSwarmStart(rigPath, msg)
result.Handled = handlerResult.Handled
result.Action = handlerResult.Action
result.WispCreated = handlerResult.WispCreated
if handlerResult.Error != nil {
result.Error = handlerResult.Error.Error()
}
case witness.ProtoHandoff:
// Handoff messages are handled by the Claude agent reading them, not by Go code
result.Handled = false
result.Action = "handoff message - read by Claude agent, not processed here"
default:
result.Handled = false
result.Action = "unknown message type, skipped"
}
return result
}

View File

@@ -397,7 +397,7 @@ func createCleanupWisp(workDir, polecatName, issueID, branch string) (string, er
labels := strings.Join(CleanupWispLabels(polecatName, "pending"), ",")
output, err := util.ExecWithOutput(workDir, "bd", "create",
"--wisp",
"--ephemeral",
"--title", title,
"--description", description,
"--labels", labels,
@@ -431,7 +431,7 @@ func createSwarmWisp(workDir string, payload *SwarmStartPayload) (string, error)
labels := strings.Join(SwarmWispLabels(payload.SwarmID, payload.Total, 0, payload.StartedAt), ",")
output, err := util.ExecWithOutput(workDir, "bd", "create",
"--wisp",
"--ephemeral",
"--title", title,
"--description", description,
"--labels", labels,
@@ -450,7 +450,7 @@ func createSwarmWisp(workDir string, payload *SwarmStartPayload) (string, error)
// findCleanupWisp finds an existing cleanup wisp for a polecat.
func findCleanupWisp(workDir, polecatName string) (string, error) {
output, err := util.ExecWithOutput(workDir, "bd", "list",
"--wisp",
"--ephemeral",
"--labels", fmt.Sprintf("polecat:%s,state:merge-requested", polecatName),
"--status", "open",
"--json",