Add EnsureGitignorePatterns to rig package that ensures .gitignore has required Gas Town patterns (.runtime/, .claude/, .beads/, .logs/). Called from crew and polecat managers when creating new workers. This prevents runtime-gitignore warnings from gt doctor. The function: - Creates .gitignore if it doesn't exist - Appends missing patterns to existing files - Recognizes pattern variants (.runtime vs .runtime/) - Adds "# Gas Town" header when appending Includes comprehensive tests for all scenarios.
1128 lines
39 KiB
Go
1128 lines
39 KiB
Go
// 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: "<prefix>-<rig>-polecat-<name>" (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/<name>/ - 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/<name>/<rigname>/ - gives LLMs recognizable repo context.
|
|
// Falls back to old structure: polecats/<name>/ for backward compatibility.
|
|
func (m *Manager) clonePath(name string) string {
|
|
// New structure: polecats/<name>/<rigname>/
|
|
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/<name>/ (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/<name>-<timestamp>).
|
|
// 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/<name>/<rigname>/ for LLM ergonomics
|
|
// The polecat's home dir is polecats/<name>/, worktree is polecats/<name>/<rigname>/
|
|
polecatDir := m.polecatDir(name)
|
|
clonePath := filepath.Join(polecatDir, m.rig.Name)
|
|
|
|
// Branch naming: include issue ID when available for better traceability.
|
|
// Format: polecat/<worker>/<issue>@<timestamp> 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/<name>/)
|
|
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/<default-branch> 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/<name>-<timestamp> <path> <startpoint>
|
|
// Worktree goes in polecats/<name>/<rigname>/ 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/<name>/)
|
|
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/<name>/ contains only polecats/<name>/<rigname>/)
|
|
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/<default-branch>.
|
|
// 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/<name>-<timestamp>).
|
|
// 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/<name>/<rigname>/
|
|
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/<default-branch> 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/<name>/<rigname>/)
|
|
// and old (polecats/<name>/) 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-<rig>-<polecat>
|
|
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/<defaultBranch>.
|
|
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"
|
|
}
|