feat: Add gt deacon trigger-pending command (gt-t5uk)
Implements auto-triggering of polecats after spawn: - New pending.go in deacon package tracks pending spawns - CheckInboxForSpawns reads POLECAT_STARTED messages - TriggerPendingSpawns polls WaitForClaudeReady and nudges when ready - PruneStalePending removes spawns older than 5 minutes The Deacon can now call `gt deacon trigger-pending` during patrol to automatically send "Begin." to newly spawned polecats once Claude is ready. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -91,6 +91,20 @@ Examples:
|
|||||||
RunE: runDeaconHeartbeat,
|
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() {
|
func init() {
|
||||||
deaconCmd.AddCommand(deaconStartCmd)
|
deaconCmd.AddCommand(deaconStartCmd)
|
||||||
deaconCmd.AddCommand(deaconStopCmd)
|
deaconCmd.AddCommand(deaconStopCmd)
|
||||||
@@ -98,6 +112,11 @@ func init() {
|
|||||||
deaconCmd.AddCommand(deaconStatusCmd)
|
deaconCmd.AddCommand(deaconStatusCmd)
|
||||||
deaconCmd.AddCommand(deaconRestartCmd)
|
deaconCmd.AddCommand(deaconRestartCmd)
|
||||||
deaconCmd.AddCommand(deaconHeartbeatCmd)
|
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)
|
rootCmd.AddCommand(deaconCmd)
|
||||||
}
|
}
|
||||||
@@ -302,3 +321,59 @@ func runDeaconHeartbeat(cmd *cobra.Command, args []string) error {
|
|||||||
|
|
||||||
return nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
255
internal/deacon/pending.go
Normal file
255
internal/deacon/pending.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user