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 <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-12-16 14:22:52 -08:00
parent 163b06582c
commit f8c177e17b
2 changed files with 258 additions and 5 deletions

View File

@@ -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
}

237
internal/swarm/landing.go Normal file
View File

@@ -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)
}