diff --git a/internal/cmd/deacon.go b/internal/cmd/deacon.go index 8bba459f..46534635 100644 --- a/internal/cmd/deacon.go +++ b/internal/cmd/deacon.go @@ -91,6 +91,20 @@ Examples: RunE: runDeaconHeartbeat, } +var deaconTriggerPendingCmd = &cobra.Command{ + Use: "trigger-pending", + Short: "Trigger pending polecat spawns", + Long: `Check inbox for POLECAT_STARTED messages and trigger ready polecats. + +When gt spawn creates a new polecat, Claude takes 10-20 seconds to initialize. +This command polls pending spawns and sends "Begin." when Claude is ready. + +This is typically called during the Deacon's patrol loop.`, + RunE: runDeaconTriggerPending, +} + +var triggerTimeout time.Duration + func init() { deaconCmd.AddCommand(deaconStartCmd) deaconCmd.AddCommand(deaconStopCmd) @@ -98,6 +112,11 @@ func init() { deaconCmd.AddCommand(deaconStatusCmd) deaconCmd.AddCommand(deaconRestartCmd) deaconCmd.AddCommand(deaconHeartbeatCmd) + deaconCmd.AddCommand(deaconTriggerPendingCmd) + + // Flags for trigger-pending + deaconTriggerPendingCmd.Flags().DurationVar(&triggerTimeout, "timeout", 2*time.Second, + "Timeout for checking if Claude is ready") rootCmd.AddCommand(deaconCmd) } @@ -302,3 +321,59 @@ func runDeaconHeartbeat(cmd *cobra.Command, args []string) error { return nil } + +func runDeaconTriggerPending(cmd *cobra.Command, args []string) error { + townRoot, err := workspace.FindFromCwdOrError() + if err != nil { + return fmt.Errorf("not in a Gas Town workspace: %w", err) + } + + // Step 1: Check inbox for new POLECAT_STARTED messages + pending, err := deacon.CheckInboxForSpawns(townRoot) + if err != nil { + return fmt.Errorf("checking inbox: %w", err) + } + + if len(pending) == 0 { + fmt.Printf("%s No pending spawns\n", style.Dim.Render("○")) + return nil + } + + fmt.Printf("%s Found %d pending spawn(s)\n", style.Bold.Render("●"), len(pending)) + + // Step 2: Try to trigger each pending spawn + results, err := deacon.TriggerPendingSpawns(townRoot, triggerTimeout) + if err != nil { + return fmt.Errorf("triggering: %w", err) + } + + // Report results + triggered := 0 + for _, r := range results { + if r.Triggered { + triggered++ + fmt.Printf(" %s Triggered %s/%s\n", + style.Bold.Render("✓"), + r.Spawn.Rig, r.Spawn.Polecat) + } else if r.Error != nil { + fmt.Printf(" %s %s/%s: %v\n", + style.Dim.Render("⚠"), + r.Spawn.Rig, r.Spawn.Polecat, r.Error) + } + } + + // Step 3: Prune stale pending spawns (older than 5 minutes) + pruned, _ := deacon.PruneStalePending(townRoot, 5*time.Minute) + if pruned > 0 { + fmt.Printf(" %s Pruned %d stale spawn(s)\n", style.Dim.Render("○"), pruned) + } + + // Summary + remaining := len(pending) - triggered + if remaining > 0 { + fmt.Printf("%s %d spawn(s) still waiting for Claude\n", + style.Dim.Render("○"), remaining) + } + + return nil +} diff --git a/internal/deacon/pending.go b/internal/deacon/pending.go new file mode 100644 index 00000000..ce7ebbc6 --- /dev/null +++ b/internal/deacon/pending.go @@ -0,0 +1,255 @@ +// Package deacon provides the Deacon agent infrastructure. +package deacon + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/steveyegge/gastown/internal/mail" + "github.com/steveyegge/gastown/internal/tmux" +) + +// PendingSpawn represents a polecat that has been spawned but not yet triggered. +type PendingSpawn struct { + // Rig is the rig name (e.g., "gastown") + Rig string `json:"rig"` + + // Polecat is the polecat name (e.g., "p-abc123") + Polecat string `json:"polecat"` + + // Session is the tmux session name + Session string `json:"session"` + + // Issue is the assigned issue ID + Issue string `json:"issue"` + + // SpawnedAt is when the spawn was detected + SpawnedAt time.Time `json:"spawned_at"` + + // MailID is the ID of the POLECAT_STARTED message + MailID string `json:"mail_id"` +} + +// PendingFile returns the path to the pending spawns file. +func PendingFile(townRoot string) string { + return filepath.Join(townRoot, "deacon", "pending.json") +} + +// LoadPending loads the pending spawns from disk. +func LoadPending(townRoot string) ([]*PendingSpawn, error) { + path := PendingFile(townRoot) + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, err + } + + var pending []*PendingSpawn + if err := json.Unmarshal(data, &pending); err != nil { + return nil, err + } + return pending, nil +} + +// SavePending saves the pending spawns to disk. +func SavePending(townRoot string, pending []*PendingSpawn) error { + path := PendingFile(townRoot) + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return err + } + + data, err := json.MarshalIndent(pending, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, data, 0644) +} + +// CheckInboxForSpawns reads the Deacon's inbox for POLECAT_STARTED messages +// and adds them to the pending list. +func CheckInboxForSpawns(townRoot string) ([]*PendingSpawn, error) { + // Get Deacon's mailbox + router := mail.NewRouter(townRoot) + mailbox, err := router.GetMailbox("deacon/") + if err != nil { + return nil, fmt.Errorf("getting deacon mailbox: %w", err) + } + + // Get unread messages + messages, err := mailbox.ListUnread() + if err != nil { + return nil, fmt.Errorf("listing unread: %w", err) + } + + // Load existing pending + pending, err := LoadPending(townRoot) + if err != nil { + return nil, fmt.Errorf("loading pending: %w", err) + } + + // Track existing by mail ID to avoid duplicates + existing := make(map[string]bool) + for _, p := range pending { + existing[p.MailID] = true + } + + // Look for POLECAT_STARTED messages + for _, msg := range messages { + if !strings.HasPrefix(msg.Subject, "POLECAT_STARTED ") { + continue + } + + // Skip if already tracked + if existing[msg.ID] { + continue + } + + // Parse subject: "POLECAT_STARTED rig/polecat" + parts := strings.SplitN(strings.TrimPrefix(msg.Subject, "POLECAT_STARTED "), "/", 2) + if len(parts) != 2 { + continue + } + + rig := parts[0] + polecat := parts[1] + + // Parse body for session and issue + var session, issue string + for _, line := range strings.Split(msg.Body, "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "Session: ") { + session = strings.TrimPrefix(line, "Session: ") + } else if strings.HasPrefix(line, "Issue: ") { + issue = strings.TrimPrefix(line, "Issue: ") + } + } + + ps := &PendingSpawn{ + Rig: rig, + Polecat: polecat, + Session: session, + Issue: issue, + SpawnedAt: msg.Timestamp, + MailID: msg.ID, + } + pending = append(pending, ps) + existing[msg.ID] = true + + // Mark message as read + _ = mailbox.MarkRead(msg.ID) + } + + // Save updated pending list + if err := SavePending(townRoot, pending); err != nil { + return nil, fmt.Errorf("saving pending: %w", err) + } + + return pending, nil +} + +// TriggerResult holds the result of attempting to trigger a pending spawn. +type TriggerResult struct { + Spawn *PendingSpawn + Triggered bool + Error error +} + +// TriggerPendingSpawns polls each pending spawn and triggers when ready. +// Returns the spawns that were successfully triggered. +func TriggerPendingSpawns(townRoot string, timeout time.Duration) ([]TriggerResult, error) { + pending, err := LoadPending(townRoot) + if err != nil { + return nil, fmt.Errorf("loading pending: %w", err) + } + + if len(pending) == 0 { + return nil, nil + } + + t := tmux.NewTmux() + var results []TriggerResult + var remaining []*PendingSpawn + + for _, ps := range pending { + result := TriggerResult{Spawn: ps} + + // Check if session still exists + running, err := t.HasSession(ps.Session) + if err != nil { + result.Error = fmt.Errorf("checking session: %w", err) + results = append(results, result) + remaining = append(remaining, ps) + continue + } + + if !running { + // Session gone - remove from pending + result.Error = fmt.Errorf("session no longer exists") + results = append(results, result) + continue + } + + // Check if Claude is ready (non-blocking poll) + err = t.WaitForClaudeReady(ps.Session, timeout) + if err != nil { + // Not ready yet - keep in pending + remaining = append(remaining, ps) + continue + } + + // Claude is ready - send trigger + triggerMsg := "Begin." + if err := t.NudgeSession(ps.Session, triggerMsg); err != nil { + result.Error = fmt.Errorf("nudging session: %w", err) + results = append(results, result) + remaining = append(remaining, ps) + continue + } + + // Successfully triggered + result.Triggered = true + results = append(results, result) + } + + // Save remaining (untriggered) spawns + if err := SavePending(townRoot, remaining); err != nil { + return results, fmt.Errorf("saving remaining: %w", err) + } + + return results, nil +} + +// PruneStalePending removes pending spawns older than the given age. +// Spawns that are too old likely had their sessions die. +func PruneStalePending(townRoot string, maxAge time.Duration) (int, error) { + pending, err := LoadPending(townRoot) + if err != nil { + return 0, err + } + + cutoff := time.Now().Add(-maxAge) + var remaining []*PendingSpawn + pruned := 0 + + for _, ps := range pending { + if ps.SpawnedAt.Before(cutoff) { + pruned++ + } else { + remaining = append(remaining, ps) + } + } + + if pruned > 0 { + if err := SavePending(townRoot, remaining); err != nil { + return pruned, err + } + } + + return pruned, nil +}