- Create mayor.Manager for mayor lifecycle (Start/Stop/IsRunning/Status) - Create deacon.Manager for deacon lifecycle with respawn loop - Move session.Manager to polecat.SessionManager (clearer naming) - Add zombie session detection for mayor/deacon (kills tmux if Claude dead) - Remove duplicate session startup code from up.go, start.go, mayor.go - Rename sessMgr -> polecatMgr for consistency - Make witness/refinery SessionName() public for status display All agent types now follow the same Manager pattern: mgr := agent.NewManager(...) mgr.Start(...) mgr.Stop() mgr.IsRunning() mgr.Status() 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
238 lines
6.0 KiB
Go
238 lines
6.0 KiB
Go
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
|
|
}
|