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:
@@ -498,7 +498,7 @@ func runSwarmLand(cmd *cobra.Command, args []string) error {
|
|||||||
swarmID := args[0]
|
swarmID := args[0]
|
||||||
|
|
||||||
// Find the swarm
|
// Find the swarm
|
||||||
rigs, _, err := getAllRigs()
|
rigs, townRoot, err := getAllRigs()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -525,9 +525,9 @@ func runSwarmLand(cmd *cobra.Command, args []string) error {
|
|||||||
|
|
||||||
sw := store.Swarms[swarmID]
|
sw := store.Swarms[swarmID]
|
||||||
|
|
||||||
// Check state
|
// Check state - allow merging or active
|
||||||
if sw.State != swarm.SwarmMerging {
|
if sw.State != swarm.SwarmMerging && sw.State != swarm.SwarmActive {
|
||||||
return fmt.Errorf("swarm must be in 'merging' state to land (current: %s)", sw.State)
|
return fmt.Errorf("swarm must be in 'active' or 'merging' state to land (current: %s)", sw.State)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create manager and land
|
// 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)
|
fmt.Printf("Landing swarm %s to %s...\n", swarmID, sw.TargetBranch)
|
||||||
|
|
||||||
|
// First, merge integration branch to main
|
||||||
if err := mgr.LandToMain(swarmID); err != nil {
|
if err := mgr.LandToMain(swarmID); err != nil {
|
||||||
return fmt.Errorf("landing swarm: %w", err)
|
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.State = swarm.SwarmLanded
|
||||||
sw.UpdatedAt = time.Now()
|
sw.UpdatedAt = time.Now()
|
||||||
if err := store.Save(); err != nil {
|
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("%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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
237
internal/swarm/landing.go
Normal file
237
internal/swarm/landing.go
Normal 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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user