// Liftoff test: 2026-01-09T14:30:00 package polecat import ( "errors" "fmt" "os" "os/exec" "path/filepath" "strconv" "strings" "time" "github.com/steveyegge/gastown/internal/beads" "github.com/steveyegge/gastown/internal/config" "github.com/steveyegge/gastown/internal/git" "github.com/steveyegge/gastown/internal/rig" "github.com/steveyegge/gastown/internal/tmux" "github.com/steveyegge/gastown/internal/workspace" ) // Common errors var ( ErrPolecatExists = errors.New("polecat already exists") ErrPolecatNotFound = errors.New("polecat not found") ErrHasChanges = errors.New("polecat has uncommitted changes") ErrHasUncommittedWork = errors.New("polecat has uncommitted work") ) // UncommittedWorkError provides details about uncommitted work. type UncommittedWorkError struct { PolecatName string Status *git.UncommittedWorkStatus } func (e *UncommittedWorkError) Error() string { return fmt.Sprintf("polecat %s has uncommitted work: %s", e.PolecatName, e.Status.String()) } func (e *UncommittedWorkError) Unwrap() error { return ErrHasUncommittedWork } // Manager handles polecat lifecycle. type Manager struct { rig *rig.Rig git *git.Git beads *beads.Beads namePool *NamePool tmux *tmux.Tmux } // NewManager creates a new polecat manager. func NewManager(r *rig.Rig, g *git.Git, t *tmux.Tmux) *Manager { // Use the resolved beads directory to find where bd commands should run. // For tracked beads: rig/.beads/redirect -> mayor/rig/.beads, so use mayor/rig // For local beads: rig/.beads is the database, so use rig root resolvedBeads := beads.ResolveBeadsDir(r.Path) beadsPath := filepath.Dir(resolvedBeads) // Get the directory containing .beads // Try to load rig settings for namepool config settingsPath := filepath.Join(r.Path, "settings", "config.json") var pool *NamePool settings, err := config.LoadRigSettings(settingsPath) if err == nil && settings.Namepool != nil { // Use configured namepool settings pool = NewNamePoolWithConfig( r.Path, r.Name, settings.Namepool.Style, settings.Namepool.Names, settings.Namepool.MaxBeforeNumbering, ) } else { // Use defaults pool = NewNamePool(r.Path, r.Name) } _ = pool.Load() // non-fatal: state file may not exist for new rigs return &Manager{ rig: r, git: g, beads: beads.NewWithBeadsDir(beadsPath, resolvedBeads), namePool: pool, tmux: t, } } // assigneeID returns the beads assignee identifier for a polecat. // Format: "rig/polecatName" (e.g., "gastown/Toast") func (m *Manager) assigneeID(name string) string { return fmt.Sprintf("%s/%s", m.rig.Name, name) } // agentBeadID returns the agent bead ID for a polecat. // Format: "--polecat-" (e.g., "gt-gastown-polecat-Toast", "bd-beads-polecat-obsidian") // The prefix is looked up from routes.jsonl to support rigs with custom prefixes. func (m *Manager) agentBeadID(name string) string { // Find town root to lookup prefix from routes.jsonl townRoot, err := workspace.Find(m.rig.Path) if err != nil || townRoot == "" { // Fall back to default prefix return beads.PolecatBeadID(m.rig.Name, name) } prefix := beads.GetPrefixForRig(townRoot, m.rig.Name) return beads.PolecatBeadIDWithPrefix(prefix, m.rig.Name, name) } // getCleanupStatusFromBead reads the cleanup_status from the polecat's agent bead. // Returns CleanupUnknown if the bead doesn't exist or has no cleanup_status. // ZFC #10: This is the ZFC-compliant way to check if removal is safe. func (m *Manager) getCleanupStatusFromBead(name string) CleanupStatus { agentID := m.agentBeadID(name) _, fields, err := m.beads.GetAgentBead(agentID) if err != nil || fields == nil { return CleanupUnknown } if fields.CleanupStatus == "" { return CleanupUnknown } return CleanupStatus(fields.CleanupStatus) } // checkCleanupStatus validates the cleanup status against removal safety rules. // Returns an error if removal should be blocked based on the status. // force=true: allow has_uncommitted, block has_stash and has_unpushed // force=false: block all non-clean statuses func (m *Manager) checkCleanupStatus(name string, status CleanupStatus, force bool) error { // Clean status is always safe if status.IsSafe() { return nil } // With force, uncommitted changes can be bypassed if force && status.CanForceRemove() { return nil } // Map status to appropriate error switch status { case CleanupUncommitted: return &UncommittedWorkError{ PolecatName: name, Status: &git.UncommittedWorkStatus{HasUncommittedChanges: true}, } case CleanupStash: return &UncommittedWorkError{ PolecatName: name, Status: &git.UncommittedWorkStatus{StashCount: 1}, } case CleanupUnpushed: return &UncommittedWorkError{ PolecatName: name, Status: &git.UncommittedWorkStatus{UnpushedCommits: 1}, } default: // Unknown status - be conservative and block return &UncommittedWorkError{ PolecatName: name, Status: &git.UncommittedWorkStatus{HasUncommittedChanges: true}, } } } // repoBase returns the git directory and Git object to use for worktree operations. // Prefers the shared bare repo (.repo.git) if it exists, otherwise falls back to mayor/rig. // The bare repo architecture allows all worktrees (refinery, polecats) to share branch visibility. func (m *Manager) repoBase() (*git.Git, error) { // First check for shared bare repo (new architecture) bareRepoPath := filepath.Join(m.rig.Path, ".repo.git") if info, err := os.Stat(bareRepoPath); err == nil && info.IsDir() { // Bare repo exists - use it return git.NewGitWithDir(bareRepoPath, ""), nil } // Fall back to mayor/rig (legacy architecture) mayorPath := filepath.Join(m.rig.Path, "mayor", "rig") if _, err := os.Stat(mayorPath); os.IsNotExist(err) { return nil, fmt.Errorf("no repo base found (neither .repo.git nor mayor/rig exists)") } return git.NewGit(mayorPath), nil } // polecatDir returns the parent directory for a polecat. // This is polecats// - the polecat's home directory. func (m *Manager) polecatDir(name string) string { return filepath.Join(m.rig.Path, "polecats", name) } // clonePath returns the path where the git worktree lives. // New structure: polecats/// - gives LLMs recognizable repo context. // Falls back to old structure: polecats// for backward compatibility. func (m *Manager) clonePath(name string) string { // New structure: polecats/// newPath := filepath.Join(m.rig.Path, "polecats", name, m.rig.Name) if info, err := os.Stat(newPath); err == nil && info.IsDir() { return newPath } // Old structure: polecats// (backward compat) oldPath := filepath.Join(m.rig.Path, "polecats", name) if info, err := os.Stat(oldPath); err == nil && info.IsDir() { // Check if this is actually a git worktree (has .git file or dir) gitPath := filepath.Join(oldPath, ".git") if _, err := os.Stat(gitPath); err == nil { return oldPath } } // Default to new structure for new polecats return newPath } // exists checks if a polecat exists. func (m *Manager) exists(name string) bool { _, err := os.Stat(m.polecatDir(name)) return err == nil } // AddOptions configures polecat creation. type AddOptions struct { HookBead string // Bead ID to set as hook_bead at spawn time (atomic assignment) } // Add creates a new polecat as a git worktree from the repo base. // Uses the shared bare repo (.repo.git) if available, otherwise mayor/rig. // This is much faster than a full clone and shares objects with all worktrees. // Polecat state is derived from beads assignee field, not state.json. // // Branch naming: Each polecat run gets a unique branch (polecat/-). // This prevents drift issues from stale branches and ensures a clean starting state. // Old branches are ephemeral and never pushed to origin. func (m *Manager) Add(name string) (*Polecat, error) { return m.AddWithOptions(name, AddOptions{}) } // AddWithOptions creates a new polecat with the specified options. // This allows setting hook_bead atomically at creation time, avoiding // cross-beads routing issues when slinging work to new polecats. func (m *Manager) AddWithOptions(name string, opts AddOptions) (*Polecat, error) { if m.exists(name) { return nil, ErrPolecatExists } // New structure: polecats/// for LLM ergonomics // The polecat's home dir is polecats//, worktree is polecats/// polecatDir := m.polecatDir(name) clonePath := filepath.Join(polecatDir, m.rig.Name) // Branch naming: include issue ID when available for better traceability. // Format: polecat//@ when HookBead is set // The @timestamp suffix ensures uniqueness if the same issue is re-slung. // parseBranchName strips the @suffix to extract the issue ID. timestamp := strconv.FormatInt(time.Now().UnixMilli(), 36) var branchName string if opts.HookBead != "" { branchName = fmt.Sprintf("polecat/%s/%s@%s", name, opts.HookBead, timestamp) } else { // Fallback to timestamp format when no issue is known at spawn time branchName = fmt.Sprintf("polecat/%s-%s", name, timestamp) } // Create polecat directory (polecats//) if err := os.MkdirAll(polecatDir, 0755); err != nil { return nil, fmt.Errorf("creating polecat dir: %w", err) } // Get the repo base (bare repo or mayor/rig) repoGit, err := m.repoBase() if err != nil { return nil, fmt.Errorf("finding repo base: %w", err) } // Fetch latest from origin to ensure worktree starts from up-to-date code if err := repoGit.Fetch("origin"); err != nil { // Non-fatal - proceed with potentially stale code fmt.Printf("Warning: could not fetch origin: %v\n", err) } // Determine the start point for the new worktree // Use origin/ to ensure we start from the rig's configured branch defaultBranch := "main" if rigCfg, err := rig.LoadRigConfig(m.rig.Path); err == nil && rigCfg.DefaultBranch != "" { defaultBranch = rigCfg.DefaultBranch } startPoint := fmt.Sprintf("origin/%s", defaultBranch) // Always create fresh branch - unique name guarantees no collision // git worktree add -b polecat/- // Worktree goes in polecats/// for LLM ergonomics if err := repoGit.WorktreeAddFromRef(clonePath, branchName, startPoint); err != nil { return nil, fmt.Errorf("creating worktree from %s: %w", startPoint, err) } // Ensure AGENTS.md exists - critical for polecats to "land the plane" // Fall back to copy from mayor/rig if not in git (e.g., stale fetch, local-only file) agentsMDPath := filepath.Join(clonePath, "AGENTS.md") if _, err := os.Stat(agentsMDPath); os.IsNotExist(err) { srcPath := filepath.Join(m.rig.Path, "mayor", "rig", "AGENTS.md") if srcData, readErr := os.ReadFile(srcPath); readErr == nil { if writeErr := os.WriteFile(agentsMDPath, srcData, 0644); writeErr != nil { fmt.Printf("Warning: could not copy AGENTS.md: %v\n", writeErr) } } } // NOTE: We intentionally do NOT write to CLAUDE.md here. // Gas Town context is injected ephemerally via SessionStart hook (gt prime). // Writing to CLAUDE.md would overwrite project instructions and could leak // Gas Town internals into the project repo if merged. // Set up shared beads: polecat uses rig's .beads via redirect file. // This eliminates git sync overhead - all polecats share one database. if err := m.setupSharedBeads(clonePath); err != nil { // Non-fatal - polecat can still work with local beads // Log warning but don't fail the spawn fmt.Printf("Warning: could not set up shared beads: %v\n", err) } // Provision PRIME.md with Gas Town context for this worker. // This is the fallback if SessionStart hook fails - ensures polecats // always have GUPP and essential Gas Town context. if err := beads.ProvisionPrimeMDForWorktree(clonePath); err != nil { // Non-fatal - polecat can still work via hook, warn but don't fail fmt.Printf("Warning: could not provision PRIME.md: %v\n", err) } // Copy overlay files from .runtime/overlay/ to polecat root. // This allows services to have .env and other config files at their root. if err := rig.CopyOverlay(m.rig.Path, clonePath); err != nil { // Non-fatal - log warning but continue fmt.Printf("Warning: could not copy overlay files: %v\n", err) } // Ensure .gitignore has required Gas Town patterns if err := rig.EnsureGitignorePatterns(clonePath); err != nil { fmt.Printf("Warning: could not update .gitignore: %v\n", err) } // Run setup hooks from .runtime/setup-hooks/. // These hooks can inject local git config, copy secrets, or perform other setup tasks. if err := rig.RunSetupHooks(m.rig.Path, clonePath); err != nil { // Non-fatal - log warning but continue fmt.Printf("Warning: could not run setup hooks: %v\n", err) } // NOTE: Slash commands (.claude/commands/) are provisioned at town level by gt install. // All agents inherit them via Claude's directory traversal - no per-workspace copies needed. // Create or reopen agent bead for ZFC compliance (self-report state). // State starts as "spawning" - will be updated to "working" when Claude starts. // HookBead is set atomically at creation time if provided (avoids cross-beads routing issues). // Uses CreateOrReopenAgentBead to handle re-spawning with same name (GH #332). agentID := m.agentBeadID(name) _, err = m.beads.CreateOrReopenAgentBead(agentID, agentID, &beads.AgentFields{ RoleType: "polecat", Rig: m.rig.Name, AgentState: "spawning", HookBead: opts.HookBead, // Set atomically at spawn time }) if err != nil { // Non-fatal - log warning but continue fmt.Printf("Warning: could not create agent bead: %v\n", err) } // Return polecat with working state (transient model: polecats are spawned with work) // State is derived from beads, not stored in state.json now := time.Now() polecat := &Polecat{ Name: name, Rig: m.rig.Name, State: StateWorking, // Transient model: polecat spawns with work ClonePath: clonePath, Branch: branchName, CreatedAt: now, UpdatedAt: now, } return polecat, nil } // Remove deletes a polecat worktree. // If force is true, removes even with uncommitted changes (but not stashes/unpushed). // Use nuclear=true to bypass ALL safety checks. func (m *Manager) Remove(name string, force bool) error { return m.RemoveWithOptions(name, force, false) } // RemoveWithOptions deletes a polecat worktree with explicit control over safety checks. // force=true: bypass uncommitted changes check (legacy behavior) // nuclear=true: bypass ALL safety checks including stashes and unpushed commits // // ZFC #10: Uses cleanup_status from agent bead if available (polecat self-report), // falls back to git check for backward compatibility. func (m *Manager) RemoveWithOptions(name string, force, nuclear bool) error { if !m.exists(name) { return ErrPolecatNotFound } // Clone path is where the git worktree lives (new or old structure) clonePath := m.clonePath(name) // Polecat dir is the parent directory (polecats//) polecatDir := m.polecatDir(name) // Check for uncommitted work unless bypassed if !nuclear { // ZFC #10: First try to read cleanup_status from agent bead // This is the ZFC-compliant path - trust what the polecat reported cleanupStatus := m.getCleanupStatusFromBead(name) if cleanupStatus != CleanupUnknown { // ZFC path: Use polecat's self-reported status if err := m.checkCleanupStatus(name, cleanupStatus, force); err != nil { return err } } else { // Fallback path: Check git directly (for polecats that haven't reported yet) polecatGit := git.NewGit(clonePath) status, err := polecatGit.CheckUncommittedWork() if err == nil && !status.Clean() { // For backward compatibility: force only bypasses uncommitted changes, not stashes/unpushed if force { // Force mode: allow uncommitted changes but still block on stashes/unpushed if status.StashCount > 0 || status.UnpushedCommits > 0 { return &UncommittedWorkError{PolecatName: name, Status: status} } } else { return &UncommittedWorkError{PolecatName: name, Status: status} } } } } // Get repo base to remove the worktree properly repoGit, err := m.repoBase() if err != nil { // Best-effort: try to prune stale worktree entries from both possible repo locations. // This handles edge cases where the repo base is corrupted but worktree entries exist. bareRepoPath := filepath.Join(m.rig.Path, ".repo.git") if info, statErr := os.Stat(bareRepoPath); statErr == nil && info.IsDir() { bareGit := git.NewGitWithDir(bareRepoPath, "") _ = bareGit.WorktreePrune() } mayorRigPath := filepath.Join(m.rig.Path, "mayor", "rig") if info, statErr := os.Stat(mayorRigPath); statErr == nil && info.IsDir() { mayorGit := git.NewGit(mayorRigPath) _ = mayorGit.WorktreePrune() } // Fall back to direct removal if repo base not found return os.RemoveAll(polecatDir) } // Try to remove as a worktree first (use force flag for worktree removal too) if err := repoGit.WorktreeRemove(clonePath, force); err != nil { // Fall back to direct removal if worktree removal fails // (e.g., if this is an old-style clone, not a worktree) if removeErr := os.RemoveAll(clonePath); removeErr != nil { return fmt.Errorf("removing clone path: %w", removeErr) } } else { // GT-1L3MY9: git worktree remove may leave untracked directories behind. // Clean up any leftover files (overlay files, .beads/, setup hook outputs, etc.) // Use RemoveAll to handle non-empty directories with untracked files. _ = os.RemoveAll(clonePath) } // Also remove the parent polecat directory // (for new structure: polecats// contains only polecats///) if polecatDir != clonePath { // GT-1L3MY9: Clean up any orphaned files at polecat level. // Use RemoveAll to handle non-empty directories with leftover files. _ = os.RemoveAll(polecatDir) } // Prune any stale worktree entries (non-fatal: cleanup only) _ = repoGit.WorktreePrune() // Release name back to pool if it's a pooled name (non-fatal: state file update) m.namePool.Release(name) _ = m.namePool.Save() // Close agent bead (non-fatal: may not exist or beads may not be available) // NOTE: We use CloseAndClearAgentBead instead of DeleteAgentBead because bd delete --hard // creates tombstones that cannot be reopened. agentID := m.agentBeadID(name) if err := m.beads.CloseAndClearAgentBead(agentID, "polecat removed"); err != nil { // Only log if not "not found" - it's ok if it doesn't exist if !errors.Is(err, beads.ErrNotFound) { fmt.Printf("Warning: could not close agent bead %s: %v\n", agentID, err) } } return nil } // AllocateName allocates a name from the name pool. // Returns a pooled name (polecat-01 through polecat-50) if available, // otherwise returns an overflow name (rigname-N). func (m *Manager) AllocateName() (string, error) { // First reconcile pool with existing polecats to handle stale state m.ReconcilePool() name, err := m.namePool.Allocate() if err != nil { return "", err } if err := m.namePool.Save(); err != nil { return "", fmt.Errorf("saving pool state: %w", err) } return name, nil } // ReleaseName releases a name back to the pool. // This is called when a polecat is removed. func (m *Manager) ReleaseName(name string) { m.namePool.Release(name) _ = m.namePool.Save() // non-fatal: state file update } // RepairWorktree repairs a stale polecat by removing it and creating a fresh worktree. // This is NOT for normal operation - it handles reconciliation when AllocateName // returns a name that unexpectedly already exists (stale state recovery). // // The polecat starts with the latest code from origin/. // The name is preserved (not released to pool) since we're repairing immediately. // force controls whether to bypass uncommitted changes check. // // Branch naming: Each repair gets a unique branch (polecat/-). // Old branches are left for garbage collection - they're never pushed to origin. func (m *Manager) RepairWorktree(name string, force bool) (*Polecat, error) { return m.RepairWorktreeWithOptions(name, force, AddOptions{}) } // RepairWorktreeWithOptions repairs a stale polecat and creates a fresh worktree with options. // This is NOT for normal operation - see RepairWorktree for context. // Allows setting hook_bead atomically at repair time. // After repair, uses new structure: polecats/// func (m *Manager) RepairWorktreeWithOptions(name string, force bool, opts AddOptions) (*Polecat, error) { if !m.exists(name) { return nil, ErrPolecatNotFound } // Get the old clone path (may be old or new structure) oldClonePath := m.clonePath(name) polecatGit := git.NewGit(oldClonePath) // New clone path uses new structure polecatDir := m.polecatDir(name) newClonePath := filepath.Join(polecatDir, m.rig.Name) // Get the repo base (bare repo or mayor/rig) repoGit, err := m.repoBase() if err != nil { return nil, fmt.Errorf("finding repo base: %w", err) } // Check for uncommitted work unless forced if !force { status, err := polecatGit.CheckUncommittedWork() if err == nil && !status.Clean() { return nil, &UncommittedWorkError{PolecatName: name, Status: status} } } // Close old agent bead before recreation (non-fatal) // NOTE: We use CloseAndClearAgentBead instead of DeleteAgentBead because bd delete --hard // creates tombstones that cannot be reopened. agentID := m.agentBeadID(name) if err := m.beads.CloseAndClearAgentBead(agentID, "polecat repair"); err != nil { if !errors.Is(err, beads.ErrNotFound) { fmt.Printf("Warning: could not close old agent bead %s: %v\n", agentID, err) } } // Remove the old worktree (use force for git worktree removal) if err := repoGit.WorktreeRemove(oldClonePath, true); err != nil { // Fall back to direct removal if removeErr := os.RemoveAll(oldClonePath); removeErr != nil { return nil, fmt.Errorf("removing old clone path: %w", removeErr) } } // Prune stale worktree entries (non-fatal: cleanup only) _ = repoGit.WorktreePrune() // Fetch latest from origin to ensure we have fresh commits (non-fatal: may be offline) _ = repoGit.Fetch("origin") // Ensure polecat directory exists for new structure if err := os.MkdirAll(polecatDir, 0755); err != nil { return nil, fmt.Errorf("creating polecat dir: %w", err) } // Determine the start point for the new worktree // Use origin/ to ensure we start from latest fetched commits defaultBranch := "main" if rigCfg, err := rig.LoadRigConfig(m.rig.Path); err == nil && rigCfg.DefaultBranch != "" { defaultBranch = rigCfg.DefaultBranch } startPoint := fmt.Sprintf("origin/%s", defaultBranch) // Create fresh worktree with unique branch name, starting from origin's default branch // Old branches are left behind - they're ephemeral (never pushed to origin) // and will be cleaned up by garbage collection // Branch naming: include issue ID when available for better traceability. timestamp := strconv.FormatInt(time.Now().UnixMilli(), 36) var branchName string if opts.HookBead != "" { branchName = fmt.Sprintf("polecat/%s/%s@%s", name, opts.HookBead, timestamp) } else { branchName = fmt.Sprintf("polecat/%s-%s", name, timestamp) } if err := repoGit.WorktreeAddFromRef(newClonePath, branchName, startPoint); err != nil { return nil, fmt.Errorf("creating fresh worktree from %s: %w", startPoint, err) } // Ensure AGENTS.md exists - critical for polecats to "land the plane" // Fall back to copy from mayor/rig if not in git (e.g., stale fetch, local-only file) agentsMDPath := filepath.Join(newClonePath, "AGENTS.md") if _, err := os.Stat(agentsMDPath); os.IsNotExist(err) { srcPath := filepath.Join(m.rig.Path, "mayor", "rig", "AGENTS.md") if srcData, readErr := os.ReadFile(srcPath); readErr == nil { if writeErr := os.WriteFile(agentsMDPath, srcData, 0644); writeErr != nil { fmt.Printf("Warning: could not copy AGENTS.md: %v\n", writeErr) } } } // NOTE: We intentionally do NOT write to CLAUDE.md here. // Gas Town context is injected ephemerally via SessionStart hook (gt prime). // Set up shared beads if err := m.setupSharedBeads(newClonePath); err != nil { fmt.Printf("Warning: could not set up shared beads: %v\n", err) } // Copy overlay files from .runtime/overlay/ to polecat root. if err := rig.CopyOverlay(m.rig.Path, newClonePath); err != nil { fmt.Printf("Warning: could not copy overlay files: %v\n", err) } // Ensure .gitignore has required Gas Town patterns if err := rig.EnsureGitignorePatterns(newClonePath); err != nil { fmt.Printf("Warning: could not update .gitignore: %v\n", err) } // NOTE: Slash commands inherited from town level - no per-workspace copies needed. // Create or reopen agent bead for ZFC compliance // HookBead is set atomically at recreation time if provided. // Uses CreateOrReopenAgentBead to handle re-spawning with same name (GH #332). _, err = m.beads.CreateOrReopenAgentBead(agentID, agentID, &beads.AgentFields{ RoleType: "polecat", Rig: m.rig.Name, AgentState: "spawning", HookBead: opts.HookBead, // Set atomically at spawn time }) if err != nil { fmt.Printf("Warning: could not create agent bead: %v\n", err) } // Return fresh polecat in working state (transient model: polecats are spawned with work) now := time.Now() return &Polecat{ Name: name, Rig: m.rig.Name, State: StateWorking, ClonePath: newClonePath, Branch: branchName, CreatedAt: now, UpdatedAt: now, }, nil } // ReconcilePool derives pool InUse state from existing polecat directories and active sessions. // This implements ZFC: InUse is discovered from filesystem and tmux, not tracked separately. // Called before each allocation to ensure InUse reflects reality. // // In addition to directory checks, this also: // - Kills orphaned tmux sessions (sessions without directories are broken) func (m *Manager) ReconcilePool() { // Get polecats with existing directories polecats, err := m.List() if err != nil { return } var namesWithDirs []string for _, p := range polecats { namesWithDirs = append(namesWithDirs, p.Name) } // Get names with tmux sessions var namesWithSessions []string if m.tmux != nil { poolNames := m.namePool.getNames() for _, name := range poolNames { sessionName := fmt.Sprintf("gt-%s-%s", m.rig.Name, name) hasSession, _ := m.tmux.HasSession(sessionName) if hasSession { namesWithSessions = append(namesWithSessions, name) } } } m.ReconcilePoolWith(namesWithDirs, namesWithSessions) // Prune any stale git worktree entries (handles manually deleted directories) if repoGit, err := m.repoBase(); err == nil { _ = repoGit.WorktreePrune() } } // ReconcilePoolWith reconciles the name pool given lists of names from different sources. // This is the testable core of ReconcilePool. // // - namesWithDirs: names that have existing worktree directories (in use) // - namesWithSessions: names that have tmux sessions // // Names with sessions but no directories are orphans and their sessions are killed. // Only namesWithDirs are marked as in-use for allocation. func (m *Manager) ReconcilePoolWith(namesWithDirs, namesWithSessions []string) { dirSet := make(map[string]bool) for _, name := range namesWithDirs { dirSet[name] = true } // Kill orphaned sessions (session exists but no directory) if m.tmux != nil { for _, name := range namesWithSessions { if !dirSet[name] { sessionName := fmt.Sprintf("gt-%s-%s", m.rig.Name, name) _ = m.tmux.KillSession(sessionName) } } } m.namePool.Reconcile(namesWithDirs) // Note: No Save() needed - InUse is transient state, only OverflowNext is persisted } // PoolStatus returns information about the name pool. func (m *Manager) PoolStatus() (active int, names []string) { return m.namePool.ActiveCount(), m.namePool.ActiveNames() } // List returns all polecats in the rig. func (m *Manager) List() ([]*Polecat, error) { polecatsDir := filepath.Join(m.rig.Path, "polecats") entries, err := os.ReadDir(polecatsDir) if err != nil { if os.IsNotExist(err) { return nil, nil } return nil, fmt.Errorf("reading polecats dir: %w", err) } var polecats []*Polecat for _, entry := range entries { if !entry.IsDir() { continue } if strings.HasPrefix(entry.Name(), ".") { continue } polecat, err := m.Get(entry.Name()) if err != nil { continue // Skip invalid polecats } polecats = append(polecats, polecat) } return polecats, nil } // Get returns a specific polecat by name. // State is derived from beads assignee field: // - If an issue is assigned to this polecat: StateWorking // - If no issue assigned: StateDone (ready for cleanup - transient polecats should have work) func (m *Manager) Get(name string) (*Polecat, error) { if !m.exists(name) { return nil, ErrPolecatNotFound } return m.loadFromBeads(name) } // SetState updates a polecat's state. // In the beads model, state is derived from issue status: // - StateWorking/StateActive: issue status set to in_progress // - StateDone: assignee cleared from issue (polecat ready for cleanup) // - StateStuck: issue status set to blocked (if supported) // If beads is not available, this is a no-op. func (m *Manager) SetState(name string, state State) error { if !m.exists(name) { return ErrPolecatNotFound } // Find the issue assigned to this polecat assignee := m.assigneeID(name) issue, err := m.beads.GetAssignedIssue(assignee) if err != nil { // If beads is not available, treat as no-op (state can't be changed) return nil } switch state { case StateWorking, StateActive: // Set issue to in_progress if there is one if issue != nil { status := "in_progress" if err := m.beads.Update(issue.ID, beads.UpdateOptions{Status: &status}); err != nil { return fmt.Errorf("setting issue status: %w", err) } } case StateDone: // Clear assignment when done (polecat ready for cleanup) if issue != nil { empty := "" if err := m.beads.Update(issue.ID, beads.UpdateOptions{Assignee: &empty}); err != nil { return fmt.Errorf("clearing assignee: %w", err) } } case StateStuck: // Mark issue as blocked if supported, otherwise just note in issue if issue != nil { // For now, just keep the assignment - the issue's blocked_by would indicate stuck // We could add a status="blocked" here if beads supports it } } return nil } // AssignIssue assigns an issue to a polecat by setting the issue's assignee in beads. func (m *Manager) AssignIssue(name, issue string) error { if !m.exists(name) { return ErrPolecatNotFound } // Set the issue's assignee to this polecat assignee := m.assigneeID(name) status := "in_progress" if err := m.beads.Update(issue, beads.UpdateOptions{ Assignee: &assignee, Status: &status, }); err != nil { return fmt.Errorf("setting issue assignee: %w", err) } return nil } // ClearIssue removes the issue assignment from a polecat. // In the transient model, this transitions to Done state for cleanup. // This clears the assignee from the currently assigned issue in beads. // If beads is not available, this is a no-op. func (m *Manager) ClearIssue(name string) error { if !m.exists(name) { return ErrPolecatNotFound } // Find the issue assigned to this polecat assignee := m.assigneeID(name) issue, err := m.beads.GetAssignedIssue(assignee) if err != nil { // If beads is not available, treat as no-op return nil } if issue == nil { // No issue assigned, nothing to clear return nil } // Clear the assignee from the issue empty := "" if err := m.beads.Update(issue.ID, beads.UpdateOptions{ Assignee: &empty, }); err != nil { return fmt.Errorf("clearing issue assignee: %w", err) } return nil } // loadFromBeads gets polecat info from beads assignee field. // State is simple: issue assigned → working, no issue → done (ready for cleanup). // Transient polecats should always have work; no work means ready for Witness cleanup. // We don't interpret issue status (ZFC: Go is transport, not decision-maker). func (m *Manager) loadFromBeads(name string) (*Polecat, error) { // Use clonePath which handles both new (polecats///) // and old (polecats//) structures clonePath := m.clonePath(name) // Get actual branch from worktree (branches are now timestamped) polecatGit := git.NewGit(clonePath) branchName, err := polecatGit.CurrentBranch() if err != nil { // Fall back to old format if we can't read the branch branchName = fmt.Sprintf("polecat/%s", name) } // Query beads for assigned issue assignee := m.assigneeID(name) issue, beadsErr := m.beads.GetAssignedIssue(assignee) if beadsErr != nil { // If beads query fails, return basic polecat info as working // (assume polecat is doing something if it exists) return &Polecat{ Name: name, Rig: m.rig.Name, State: StateWorking, ClonePath: clonePath, Branch: branchName, }, nil } // Transient model: has issue = working, no issue = done (ready for cleanup) // Polecats without work should be nuked by the Witness state := StateDone issueID := "" if issue != nil { issueID = issue.ID state = StateWorking } return &Polecat{ Name: name, Rig: m.rig.Name, State: state, ClonePath: clonePath, Branch: branchName, Issue: issueID, }, nil } // setupSharedBeads creates a redirect file so the polecat uses the rig's shared .beads database. // This eliminates the need for git sync between polecat clones - all polecats share one database. func (m *Manager) setupSharedBeads(clonePath string) error { townRoot := filepath.Dir(m.rig.Path) return beads.SetupRedirect(townRoot, clonePath) } // CleanupStaleBranches removes orphaned polecat branches that are no longer in use. // This includes: // - Branches for polecats that no longer exist // - Old timestamped branches (keeps only the most recent per polecat name) // Returns the number of branches deleted. func (m *Manager) CleanupStaleBranches() (int, error) { repoGit, err := m.repoBase() if err != nil { return 0, fmt.Errorf("finding repo base: %w", err) } // List all polecat branches branches, err := repoGit.ListBranches("polecat/*") if err != nil { return 0, fmt.Errorf("listing branches: %w", err) } if len(branches) == 0 { return 0, nil } // Get list of existing polecats polecats, err := m.List() if err != nil { return 0, fmt.Errorf("listing polecats: %w", err) } // Build set of current polecat branches (from actual polecat objects) currentBranches := make(map[string]bool) for _, p := range polecats { currentBranches[p.Branch] = true } // Delete branches not in current set deleted := 0 for _, branch := range branches { if currentBranches[branch] { continue // This branch is in use } // Delete orphaned branch if err := repoGit.DeleteBranch(branch, true); err != nil { // Log but continue - non-fatal fmt.Printf("Warning: could not delete branch %s: %v\n", branch, err) continue } deleted++ } return deleted, nil } // StalenessInfo contains details about a polecat's staleness. type StalenessInfo struct { Name string CommitsBehind int // How many commits behind origin/main HasActiveSession bool // Whether tmux session is running HasUncommittedWork bool // Whether there's uncommitted or unpushed work AgentState string // From agent bead (empty if no bead) IsStale bool // Overall assessment: safe to clean up Reason string // Why it's considered stale (or not) } // DetectStalePolecats identifies polecats that are candidates for cleanup. // A polecat is considered stale if: // - No active tmux session AND // - Either: way behind main (>threshold commits) OR no agent bead/activity // - Has no uncommitted work that could be lost // // threshold: minimum commits behind main to consider "way behind" (e.g., 20) func (m *Manager) DetectStalePolecats(threshold int) ([]*StalenessInfo, error) { polecats, err := m.List() if err != nil { return nil, fmt.Errorf("listing polecats: %w", err) } if len(polecats) == 0 { return nil, nil } // Get default branch from rig config defaultBranch := "main" if rigCfg, err := rig.LoadRigConfig(m.rig.Path); err == nil && rigCfg.DefaultBranch != "" { defaultBranch = rigCfg.DefaultBranch } var results []*StalenessInfo for _, p := range polecats { info := &StalenessInfo{ Name: p.Name, } // Check for active tmux session // Session name follows pattern: gt-- sessionName := fmt.Sprintf("gt-%s-%s", m.rig.Name, p.Name) info.HasActiveSession = checkTmuxSession(sessionName) // Check how far behind main polecatGit := git.NewGit(p.ClonePath) info.CommitsBehind = countCommitsBehind(polecatGit, defaultBranch) // Check for uncommitted work (excluding .beads/ files which are synced across worktrees) status, err := polecatGit.CheckUncommittedWork() if err == nil && !status.CleanExcludingBeads() { info.HasUncommittedWork = true } // Check agent bead state agentID := m.agentBeadID(p.Name) _, fields, err := m.beads.GetAgentBead(agentID) if err == nil && fields != nil { info.AgentState = fields.AgentState } // Determine staleness info.IsStale, info.Reason = assessStaleness(info, threshold) results = append(results, info) } return results, nil } // checkTmuxSession checks if a tmux session exists. func checkTmuxSession(sessionName string) bool { // Use has-session command which returns 0 if session exists cmd := exec.Command("tmux", "has-session", "-t", sessionName) //nolint:gosec // G204: sessionName is constructed internally return cmd.Run() == nil } // countCommitsBehind counts how many commits a worktree is behind origin/. func countCommitsBehind(g *git.Git, defaultBranch string) int { // Use rev-list to count commits: origin/main..HEAD shows commits ahead, // HEAD..origin/main shows commits behind remoteBranch := "origin/" + defaultBranch count, err := g.CountCommitsBehind(remoteBranch) if err != nil { return 0 // Can't determine, assume not behind } return count } // assessStaleness determines if a polecat should be cleaned up. // Per gt-zecmc: uses tmux state (HasActiveSession) rather than agent_state // since observable states (running, done, idle) are no longer recorded in beads. func assessStaleness(info *StalenessInfo, threshold int) (bool, string) { // Never clean up if there's uncommitted work if info.HasUncommittedWork { return false, "has uncommitted work" } // If session is active, not stale (tmux is source of truth for liveness) if info.HasActiveSession { return false, "session active" } // No active session - this polecat is a cleanup candidate // Check for reasons to keep it: // Check for non-observable states that indicate intentional pause // (stuck, awaiting-gate are still stored in beads per gt-zecmc) if info.AgentState == "stuck" || info.AgentState == "awaiting-gate" { return false, fmt.Sprintf("agent_state=%s (intentional pause)", info.AgentState) } // No session and way behind main = stale if info.CommitsBehind >= threshold { return true, fmt.Sprintf("%d commits behind main, no active session", info.CommitsBehind) } // No session and no agent bead = abandoned, clean up if info.AgentState == "" { return true, "no agent bead, no active session" } // No session but has agent bead without special state = clean up // (The session is the source of truth for liveness) return true, "no active session" }