package swarm import ( "bytes" "fmt" "os/exec" "strings" "time" "github.com/steveyegge/gastown/internal/mail" "github.com/steveyegge/gastown/internal/polecat" "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, err := m.LoadSwarm(swarmID) if err != nil { return nil, err } result := &LandingResult{ SwarmID: swarmID, } // Phase 1: Stop all polecat sessions t := tmux.NewTmux() polecatMgr := polecat.NewSessionManager(t, m.rig) for _, worker := range swarm.Workers { running, _ := polecatMgr.IsRunning(worker) if running { err := polecatMgr.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(_, swarmID string, workers []string) { // townRoot unused: router uses gitDir router := mail.NewRouter(m.gitDir) msg := &mail.Message{ From: fmt.Sprintf("%s/refinery", m.rig.Name), To: "mayor/", Subject: fmt.Sprintf("Code at risk in swarm %s", swarmID), Body: fmt.Sprintf(`Landing blocked for swarm %s. The following workers have uncommitted or unpushed code: %s Manual intervention required.`, swarmID, strings.Join(workers, "\n- ")), Priority: mail.PriorityHigh, } _ = router.Send(msg) // best-effort notification } // notifyMayorLanded sends a landing report to Mayor. func (m *Manager) notifyMayorLanded(_ string, swarm *Swarm, result *LandingResult) { // townRoot unused: router uses gitDir router := mail.NewRouter(m.gitDir) msg := &mail.Message{ From: fmt.Sprintf("%s/refinery", m.rig.Name), To: "mayor/", Subject: fmt.Sprintf("Swarm %s landed", swarm.ID), Body: 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) // best-effort notification }