When a polecat is nuked and re-spawned with the same name, CreateAgentBead fails with a UNIQUE constraint error because the old agent bead exists as a tombstone. This adds CreateOrReopenAgentBead that: 1. First tries to create the agent bead normally 2. If UNIQUE constraint fails, reopens the existing bead and updates fields Updated both spawn paths in polecat manager to use the new function. Fixes #332 Co-authored-by: Claude <noreply@anthropic.com>
993 lines
33 KiB
Go
993 lines
33 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/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
|
|
}
|
|
|
|
// NewManager creates a new polecat manager.
|
|
func NewManager(r *rig.Rig, g *git.Git) *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.New(beadsPath),
|
|
namePool: pool,
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
|
|
// Unique branch per run - prevents drift from stale branches
|
|
// Use base36 encoding for shorter branch names (8 chars vs 13 digits)
|
|
branchName := fmt.Sprintf("polecat/%s-%s", name, strconv.FormatInt(time.Now().UnixMilli(), 36))
|
|
|
|
// 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)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// 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",
|
|
RoleBead: beads.RoleBeadIDTown("polecat"),
|
|
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 {
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
// Also remove the parent polecat directory if it's now empty
|
|
// (for new structure: polecats/<name>/ contains only polecats/<name>/<rigname>/)
|
|
if polecatDir != clonePath {
|
|
_ = os.Remove(polecatDir) // Non-fatal: only removes if empty
|
|
}
|
|
|
|
// 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()
|
|
|
|
// Delete agent bead (non-fatal: may not exist or beads may not be available)
|
|
agentID := m.agentBeadID(name)
|
|
if err := m.beads.DeleteAgentBead(agentID); 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 delete 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}
|
|
}
|
|
}
|
|
|
|
// Delete old agent bead before recreation (non-fatal)
|
|
agentID := m.agentBeadID(name)
|
|
if err := m.beads.DeleteAgentBead(agentID); err != nil {
|
|
if !errors.Is(err, beads.ErrNotFound) {
|
|
fmt.Printf("Warning: could not delete 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
|
|
// Use base36 encoding for shorter branch names (8 chars vs 13 digits)
|
|
branchName := fmt.Sprintf("polecat/%s-%s", name, strconv.FormatInt(time.Now().UnixMilli(), 36))
|
|
if err := repoGit.WorktreeAddFromRef(newClonePath, branchName, startPoint); err != nil {
|
|
return nil, fmt.Errorf("creating fresh worktree from %s: %w", startPoint, err)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// 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",
|
|
RoleBead: beads.RoleBeadIDTown("polecat"),
|
|
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.
|
|
// This implements ZFC: InUse is discovered from filesystem, not tracked separately.
|
|
// Called before each allocation to ensure InUse reflects reality.
|
|
func (m *Manager) ReconcilePool() {
|
|
polecats, err := m.List()
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
var names []string
|
|
for _, p := range polecats {
|
|
names = append(names, p.Name)
|
|
}
|
|
|
|
m.namePool.Reconcile(names)
|
|
// 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
|
|
status, err := polecatGit.CheckUncommittedWork()
|
|
if err == nil && !status.Clean() {
|
|
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"
|
|
}
|