refactor: Move pending spawn tracking to gt spawn pending (hq-466n)

- Moved pending.go from deacon/ to polecat/ package
- Changed storage location from deacon/pending.json to spawn/pending.json
- Added 'gt spawn pending' subcommand for listing/clearing pending spawns
- Deprecated 'gt deacon pending' (still works, shows deprecation notice)

This decouples pending spawn observation from the Deacon role - anyone
can now observe pending polecats (Mayor, humans, debugging, etc.).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-12-25 13:50:26 -08:00
parent 0ba7b13c64
commit d3164f72a6
4 changed files with 222 additions and 190 deletions
+66 -167
View File
File diff suppressed because one or more lines are too long
+16 -20
View File
@@ -10,6 +10,7 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/deacon" "github.com/steveyegge/gastown/internal/deacon"
"github.com/steveyegge/gastown/internal/polecat"
"github.com/steveyegge/gastown/internal/style" "github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/tmux" "github.com/steveyegge/gastown/internal/tmux"
"github.com/steveyegge/gastown/internal/workspace" "github.com/steveyegge/gastown/internal/workspace"
@@ -112,21 +113,16 @@ This command is typically called by the daemon during cold startup.`,
var deaconPendingCmd = &cobra.Command{ var deaconPendingCmd = &cobra.Command{
Use: "pending [session-to-clear]", Use: "pending [session-to-clear]",
Short: "List pending spawns with captured output (for AI observation)", Short: "[DEPRECATED] Use 'gt spawn pending' instead",
Long: `List pending polecat spawns with their terminal output for AI analysis. Long: `DEPRECATED: Use 'gt spawn pending' instead.
This is the ZFC-compliant way for the Deacon (AI) to observe polecats: This command has been moved to 'gt spawn pending' since pending spawn
1. Run 'gt deacon pending' to see pending spawns and their output tracking is not Deacon-specific - anyone might need to observe pending
2. Analyze the output to determine if Claude is ready (look for "> " prompt) polecats (Mayor, humans, debugging, etc.).
3. Run 'gt nudge <session> "Begin."' to trigger ready polecats
4. Run 'gt deacon pending <session>' to clear from pending list
This replaces the regex-based trigger-pending for steady-state operation. The functionality is identical:
The AI makes the readiness judgment, not hardcoded regex. gt spawn pending # List all pending with output
gt spawn pending gastown/p-abc123 # Clear specific session from pending`,
Examples:
gt deacon pending # List all pending with output
gt deacon pending gastown/p-abc123 # Clear specific session from pending`,
Args: cobra.MaximumNArgs(1), Args: cobra.MaximumNArgs(1),
RunE: runDeaconPending, RunE: runDeaconPending,
} }
@@ -364,7 +360,7 @@ func runDeaconTriggerPending(cmd *cobra.Command, args []string) error {
} }
// Step 1: Check inbox for new POLECAT_STARTED messages // Step 1: Check inbox for new POLECAT_STARTED messages
pending, err := deacon.CheckInboxForSpawns(townRoot) pending, err := polecat.CheckInboxForSpawns(townRoot)
if err != nil { if err != nil {
return fmt.Errorf("checking inbox: %w", err) return fmt.Errorf("checking inbox: %w", err)
} }
@@ -377,7 +373,7 @@ func runDeaconTriggerPending(cmd *cobra.Command, args []string) error {
fmt.Printf("%s Found %d pending spawn(s)\n", style.Bold.Render("●"), len(pending)) fmt.Printf("%s Found %d pending spawn(s)\n", style.Bold.Render("●"), len(pending))
// Step 2: Try to trigger each pending spawn // Step 2: Try to trigger each pending spawn
results, err := deacon.TriggerPendingSpawns(townRoot, triggerTimeout) results, err := polecat.TriggerPendingSpawns(townRoot, triggerTimeout)
if err != nil { if err != nil {
return fmt.Errorf("triggering: %w", err) return fmt.Errorf("triggering: %w", err)
} }
@@ -398,7 +394,7 @@ func runDeaconTriggerPending(cmd *cobra.Command, args []string) error {
} }
// Step 3: Prune stale pending spawns (older than 5 minutes) // Step 3: Prune stale pending spawns (older than 5 minutes)
pruned, _ := deacon.PruneStalePending(townRoot, 5*time.Minute) pruned, _ := polecat.PruneStalePending(townRoot, 5*time.Minute)
if pruned > 0 { if pruned > 0 {
fmt.Printf(" %s Pruned %d stale spawn(s)\n", style.Dim.Render("○"), pruned) fmt.Printf(" %s Pruned %d stale spawn(s)\n", style.Dim.Render("○"), pruned)
} }
@@ -427,7 +423,7 @@ func runDeaconPending(cmd *cobra.Command, args []string) error {
} }
// Step 1: Check inbox for new POLECAT_STARTED messages // Step 1: Check inbox for new POLECAT_STARTED messages
pending, err := deacon.CheckInboxForSpawns(townRoot) pending, err := polecat.CheckInboxForSpawns(townRoot)
if err != nil { if err != nil {
return fmt.Errorf("checking inbox: %w", err) return fmt.Errorf("checking inbox: %w", err)
} }
@@ -491,12 +487,12 @@ func runDeaconPending(cmd *cobra.Command, args []string) error {
// clearPendingSession removes a session from the pending list. // clearPendingSession removes a session from the pending list.
func clearPendingSession(townRoot, session string) error { func clearPendingSession(townRoot, session string) error {
pending, err := deacon.LoadPending(townRoot) pending, err := polecat.LoadPending(townRoot)
if err != nil { if err != nil {
return fmt.Errorf("loading pending: %w", err) return fmt.Errorf("loading pending: %w", err)
} }
var remaining []*deacon.PendingSpawn var remaining []*polecat.PendingSpawn
found := false found := false
for _, ps := range pending { for _, ps := range pending {
if ps.Session == session { if ps.Session == session {
@@ -510,7 +506,7 @@ func clearPendingSession(townRoot, session string) error {
return fmt.Errorf("session %s not found in pending list", session) return fmt.Errorf("session %s not found in pending list", session)
} }
if err := deacon.SavePending(townRoot, remaining); err != nil { if err := polecat.SavePending(townRoot, remaining); err != nil {
return fmt.Errorf("saving pending: %w", err) return fmt.Errorf("saving pending: %w", err)
} }
+137
View File
@@ -7,6 +7,7 @@ import (
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strings" "strings"
"time"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/config" "github.com/steveyegge/gastown/internal/config"
@@ -35,6 +36,9 @@ var (
spawnMolecule string spawnMolecule string
spawnForce bool spawnForce bool
spawnAccount string spawnAccount string
// spawn pending flags
spawnPendingLines int
) )
var spawnCmd = &cobra.Command{ var spawnCmd = &cobra.Command{
@@ -44,6 +48,8 @@ var spawnCmd = &cobra.Command{
Short: "Spawn a polecat with work assignment", Short: "Spawn a polecat with work assignment",
Long: `Spawn a polecat with a work assignment. Long: `Spawn a polecat with a work assignment.
Use 'gt spawn pending' to view spawns waiting to be triggered.
Assigns an issue or task to a polecat and starts a session. If no polecat Assigns an issue or task to a polecat and starts a session. If no polecat
is specified, auto-selects an idle polecat in the rig. is specified, auto-selects an idle polecat in the rig.
@@ -67,6 +73,27 @@ Examples:
RunE: runSpawn, RunE: runSpawn,
} }
var spawnPendingCmd = &cobra.Command{
Use: "pending [session-to-clear]",
Short: "List pending spawns with captured output (for AI observation)",
Long: `List pending polecat spawns with their terminal output for AI analysis.
This shows spawns waiting to be triggered (Claude is still initializing).
The terminal output helps determine if Claude is ready.
Workflow:
1. Run 'gt spawn pending' to see pending spawns and their output
2. Analyze the output to determine if Claude is ready (look for "> " prompt)
3. Run 'gt nudge <session> "Begin."' to trigger ready polecats
4. Run 'gt spawn pending <session>' to clear from pending list
Examples:
gt spawn pending # List all pending with output
gt spawn pending gastown/p-abc123 # Clear specific session from pending`,
Args: cobra.MaximumNArgs(1),
RunE: runSpawnPending,
}
func init() { func init() {
spawnCmd.Flags().StringVar(&spawnIssue, "issue", "", "Beads issue ID to assign") spawnCmd.Flags().StringVar(&spawnIssue, "issue", "", "Beads issue ID to assign")
spawnCmd.Flags().StringVarP(&spawnMessage, "message", "m", "", "Free-form task description") spawnCmd.Flags().StringVarP(&spawnMessage, "message", "m", "", "Free-form task description")
@@ -78,6 +105,11 @@ func init() {
spawnCmd.Flags().BoolVar(&spawnForce, "force", false, "Force spawn even if polecat has unread mail") spawnCmd.Flags().BoolVar(&spawnForce, "force", false, "Force spawn even if polecat has unread mail")
spawnCmd.Flags().StringVar(&spawnAccount, "account", "", "Claude Code account handle to use (overrides default)") spawnCmd.Flags().StringVar(&spawnAccount, "account", "", "Claude Code account handle to use (overrides default)")
// spawn pending flags
spawnPendingCmd.Flags().IntVarP(&spawnPendingLines, "lines", "n", 15,
"Number of terminal lines to capture per session")
spawnCmd.AddCommand(spawnPendingCmd)
rootCmd.AddCommand(spawnCmd) rootCmd.AddCommand(spawnCmd)
} }
@@ -741,3 +773,108 @@ func buildWorkAssignmentMail(issue *BeadsIssue, message, polecatAddress string,
} }
} }
// runSpawnPending shows pending spawns with captured output for AI observation.
// This is the ZFC-compliant way to observe polecats waiting to be triggered.
func runSpawnPending(cmd *cobra.Command, args []string) error {
townRoot, err := workspace.FindFromCwdOrError()
if err != nil {
return fmt.Errorf("not in a Gas Town workspace: %w", err)
}
// If session argument provided, clear it from pending
if len(args) == 1 {
return clearSpawnPending(townRoot, args[0])
}
// Step 1: Check inbox for new POLECAT_STARTED messages
pending, err := polecat.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
}
t := tmux.NewTmux()
fmt.Printf("%s Pending spawns (%d):\n\n", style.Bold.Render("●"), len(pending))
for i, ps := range pending {
// Check if session still exists
running, err := t.HasSession(ps.Session)
if err != nil {
fmt.Printf("Session: %s\n", ps.Session)
fmt.Printf(" Status: error checking session: %v\n\n", err)
continue
}
if !running {
fmt.Printf("Session: %s\n", ps.Session)
fmt.Printf(" Status: session no longer exists\n\n")
continue
}
// Capture terminal output for AI analysis
output, err := t.CapturePane(ps.Session, spawnPendingLines)
if err != nil {
fmt.Printf("Session: %s\n", ps.Session)
fmt.Printf(" Status: error capturing output: %v\n\n", err)
continue
}
// Print session info
fmt.Printf("Session: %s\n", ps.Session)
fmt.Printf(" Rig: %s\n", ps.Rig)
fmt.Printf(" Polecat: %s\n", ps.Polecat)
if ps.Issue != "" {
fmt.Printf(" Issue: %s\n", ps.Issue)
}
fmt.Printf(" Spawned: %s ago\n", time.Since(ps.SpawnedAt).Round(time.Second))
fmt.Printf(" Terminal output (last %d lines):\n", spawnPendingLines)
fmt.Println(strings.Repeat("─", 50))
fmt.Println(output)
fmt.Println(strings.Repeat("─", 50))
if i < len(pending)-1 {
fmt.Println()
}
}
fmt.Println()
fmt.Printf("%s To trigger a ready polecat:\n", style.Dim.Render("→"))
fmt.Printf(" gt nudge <session> \"Begin.\"\n")
return nil
}
// clearSpawnPending removes a session from the pending list.
func clearSpawnPending(townRoot, session string) error {
pending, err := polecat.LoadPending(townRoot)
if err != nil {
return fmt.Errorf("loading pending: %w", err)
}
var remaining []*polecat.PendingSpawn
found := false
for _, ps := range pending {
if ps.Session == session {
found = true
continue
}
remaining = append(remaining, ps)
}
if !found {
return fmt.Errorf("session %s not found in pending list", session)
}
if err := polecat.SavePending(townRoot, remaining); err != nil {
return fmt.Errorf("saving pending: %w", err)
}
fmt.Printf("%s Cleared %s from pending list\n", style.Bold.Render("✓"), session)
return nil
}
@@ -1,5 +1,5 @@
// Package deacon provides the Deacon agent infrastructure. // Package polecat provides polecat lifecycle management.
package deacon package polecat
import ( import (
"encoding/json" "encoding/json"
@@ -36,7 +36,7 @@ type PendingSpawn struct {
// PendingFile returns the path to the pending spawns file. // PendingFile returns the path to the pending spawns file.
func PendingFile(townRoot string) string { func PendingFile(townRoot string) string {
return filepath.Join(townRoot, "deacon", "pending.json") return filepath.Join(townRoot, "spawn", "pending.json")
} }
// LoadPending loads the pending spawns from disk. // LoadPending loads the pending spawns from disk.