From f8c177e17b72bc96dd8d0fc39e1bcb44ffebd271 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Tue, 16 Dec 2025 14:22:52 -0800 Subject: [PATCH] feat: add witness swarm landing protocol - ExecuteLanding: Full landing protocol for swarms - Phase 1: Stop all polecat sessions - Phase 2: Git audit (uncommitted/unpushed detection) - Phase 3: Branch cleanup - Phase 4: Mail notification to Mayor - Code at risk detection with escalation - Beads-only changes considered safe - Updated gt swarm land to use full protocol Closes gt-kmn.6 Generated with Claude Code Co-Authored-By: Claude Opus 4.5 --- internal/cmd/swarm.go | 26 ++++- internal/swarm/landing.go | 237 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 258 insertions(+), 5 deletions(-) create mode 100644 internal/swarm/landing.go diff --git a/internal/cmd/swarm.go b/internal/cmd/swarm.go index eb38f997..4f3dba1c 100644 --- a/internal/cmd/swarm.go +++ b/internal/cmd/swarm.go @@ -498,7 +498,7 @@ func runSwarmLand(cmd *cobra.Command, args []string) error { swarmID := args[0] // Find the swarm - rigs, _, err := getAllRigs() + rigs, townRoot, err := getAllRigs() if err != nil { return err } @@ -525,9 +525,9 @@ func runSwarmLand(cmd *cobra.Command, args []string) error { sw := store.Swarms[swarmID] - // Check state - if sw.State != swarm.SwarmMerging { - return fmt.Errorf("swarm must be in 'merging' state to land (current: %s)", sw.State) + // Check state - allow merging or active + if sw.State != swarm.SwarmMerging && sw.State != swarm.SwarmActive { + return fmt.Errorf("swarm must be in 'active' or 'merging' state to land (current: %s)", sw.State) } // Create manager and land @@ -538,11 +538,25 @@ func runSwarmLand(cmd *cobra.Command, args []string) error { fmt.Printf("Landing swarm %s to %s...\n", swarmID, sw.TargetBranch) + // First, merge integration branch to main if err := mgr.LandToMain(swarmID); err != nil { return fmt.Errorf("landing swarm: %w", err) } - // Update state + // Execute full landing protocol (stop sessions, audit, cleanup) + config := swarm.LandingConfig{ + TownRoot: townRoot, + } + result, err := mgr.ExecuteLanding(swarmID, config) + if err != nil { + return fmt.Errorf("landing protocol: %w", err) + } + + if !result.Success { + return fmt.Errorf("landing failed: %s", result.Error) + } + + // Update store sw.State = swarm.SwarmLanded sw.UpdatedAt = time.Now() if err := store.Save(); err != nil { @@ -550,6 +564,8 @@ func runSwarmLand(cmd *cobra.Command, args []string) error { } fmt.Printf("%s Swarm %s landed to %s\n", style.Bold.Render("✓"), swarmID, sw.TargetBranch) + fmt.Printf(" Sessions stopped: %d\n", result.SessionsStopped) + fmt.Printf(" Branches cleaned: %d\n", result.BranchesCleaned) return nil } diff --git a/internal/swarm/landing.go b/internal/swarm/landing.go new file mode 100644 index 00000000..e02b7cbe --- /dev/null +++ b/internal/swarm/landing.go @@ -0,0 +1,237 @@ +package swarm + +import ( + "bytes" + "fmt" + "os/exec" + "strings" + "time" + + "github.com/steveyegge/gastown/internal/mail" + "github.com/steveyegge/gastown/internal/session" + "github.com/steveyegge/gastown/internal/tmux" +) + +// LandingConfig configures the landing protocol. +type LandingConfig struct { + // TownRoot is the workspace root for mail routing. + TownRoot string + + // ForceKill kills sessions without graceful shutdown. + ForceKill bool + + // SkipGitAudit skips the git safety audit. + SkipGitAudit bool +} + +// LandingResult contains the result of a landing operation. +type LandingResult struct { + SwarmID string + Success bool + Error string + SessionsStopped int + BranchesCleaned int + PolecatsAtRisk []string +} + +// GitAuditResult contains the result of a git safety audit. +type GitAuditResult struct { + Worker string + ClonePath string + HasUncommitted bool + HasUnpushed bool + HasStashes bool + BeadsOnly bool // True if changes are only in .beads/ + CodeAtRisk bool + Details string +} + +// ExecuteLanding performs the witness landing protocol for a swarm. +func (m *Manager) ExecuteLanding(swarmID string, config LandingConfig) (*LandingResult, error) { + swarm, ok := m.swarms[swarmID] + if !ok { + return nil, ErrSwarmNotFound + } + + result := &LandingResult{ + SwarmID: swarmID, + } + + // Phase 1: Stop all polecat sessions + t := tmux.NewTmux() + sessMgr := session.NewManager(t, m.rig) + + for _, worker := range swarm.Workers { + running, _ := sessMgr.IsRunning(worker) + if running { + err := sessMgr.Stop(worker, config.ForceKill) + if err != nil { + // Continue anyway + } else { + result.SessionsStopped++ + } + } + } + + // Wait for graceful shutdown + time.Sleep(2 * time.Second) + + // Phase 2: Git audit (check for code at risk) + if !config.SkipGitAudit { + for _, worker := range swarm.Workers { + audit := m.auditWorkerGit(worker) + if audit.CodeAtRisk { + result.PolecatsAtRisk = append(result.PolecatsAtRisk, worker) + } + } + + if len(result.PolecatsAtRisk) > 0 { + result.Success = false + result.Error = fmt.Sprintf("code at risk for workers: %s", + strings.Join(result.PolecatsAtRisk, ", ")) + + // Notify Mayor + if config.TownRoot != "" { + m.notifyMayorCodeAtRisk(config.TownRoot, swarmID, result.PolecatsAtRisk) + } + + return result, nil + } + } + + // Phase 3: Cleanup branches + if err := m.CleanupBranches(swarmID); err != nil { + // Log but continue + } + result.BranchesCleaned = len(swarm.Tasks) + 1 // tasks + integration + + // Phase 4: Update swarm state + swarm.State = SwarmLanded + swarm.UpdatedAt = time.Now() + + // Send landing report to Mayor + if config.TownRoot != "" { + m.notifyMayorLanded(config.TownRoot, swarm, result) + } + + result.Success = true + return result, nil +} + +// auditWorkerGit checks a worker's git state for uncommitted/unpushed work. +func (m *Manager) auditWorkerGit(worker string) GitAuditResult { + result := GitAuditResult{ + Worker: worker, + } + + // Get polecat clone path + clonePath := fmt.Sprintf("%s/polecats/%s", m.rig.Path, worker) + result.ClonePath = clonePath + + // Check for uncommitted changes + statusOutput, err := m.gitRunOutput(clonePath, "status", "--porcelain") + if err == nil && strings.TrimSpace(statusOutput) != "" { + result.HasUncommitted = true + // Check if only .beads changes + result.BeadsOnly = isBeadsOnlyChanges(statusOutput) + } + + // Check for unpushed commits + unpushed, err := m.gitRunOutput(clonePath, "log", "--oneline", "@{u}..", "--") + if err == nil && strings.TrimSpace(unpushed) != "" { + result.HasUnpushed = true + } + + // Check for stashes + stashes, err := m.gitRunOutput(clonePath, "stash", "list") + if err == nil && strings.TrimSpace(stashes) != "" { + result.HasStashes = true + } + + // Determine if code is at risk + if result.HasUncommitted && !result.BeadsOnly { + result.CodeAtRisk = true + result.Details = "uncommitted code changes" + } else if result.HasUnpushed { + result.CodeAtRisk = true + result.Details = "unpushed commits" + } + + return result +} + +// isBeadsOnlyChanges checks if all changes are in .beads/ directory. +func isBeadsOnlyChanges(statusOutput string) bool { + for _, line := range strings.Split(statusOutput, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + // Status format: XY filename + if len(line) > 3 { + filename := strings.TrimSpace(line[3:]) + if !strings.HasPrefix(filename, ".beads/") { + return false + } + } + } + return true +} + +// gitRunOutput runs a git command and returns stdout. +func (m *Manager) gitRunOutput(dir string, args ...string) (string, error) { + cmd := exec.Command("git", args...) + cmd.Dir = dir + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + return "", fmt.Errorf("%s", strings.TrimSpace(stderr.String())) + } + + return stdout.String(), nil +} + +// notifyMayorCodeAtRisk sends an alert to Mayor about code at risk. +func (m *Manager) notifyMayorCodeAtRisk(townRoot, swarmID string, workers []string) { + router := mail.NewRouter(townRoot) + msg := mail.NewMessage( + fmt.Sprintf("%s/refinery", m.rig.Name), + "mayor/", + fmt.Sprintf("⚠️ Code at risk in swarm %s", swarmID), + fmt.Sprintf(`Landing blocked for swarm %s. + +The following workers have uncommitted or unpushed code: +%s + +Manual intervention required.`, + swarmID, strings.Join(workers, "\n- ")), + ) + msg.Priority = mail.PriorityHigh + router.Send(msg) +} + +// notifyMayorLanded sends a landing report to Mayor. +func (m *Manager) notifyMayorLanded(townRoot string, swarm *Swarm, result *LandingResult) { + router := mail.NewRouter(townRoot) + msg := mail.NewMessage( + fmt.Sprintf("%s/refinery", m.rig.Name), + "mayor/", + fmt.Sprintf("✓ Swarm %s landed", swarm.ID), + fmt.Sprintf(`Swarm landing complete. + +Swarm: %s +Target: %s +Sessions stopped: %d +Branches cleaned: %d +Tasks merged: %d`, + swarm.ID, + swarm.TargetBranch, + result.SessionsStopped, + result.BranchesCleaned, + len(swarm.Tasks)), + ) + router.Send(msg) +}