Fixes from code review: - Remove duplicate generateDogNameForDispatch, reuse generateDogName - Fix race condition: assign work BEFORE sending mail - Add rollback if mail send fails (clear work assignment) - Fix misleading help text (was "hooks mail", actually sends mail) - Add --json flag for scripted output - Add --dry-run flag to preview without executing The order change (assign work first, then send mail) ensures that if AssignWork fails, no mail has been sent. If mail fails after work is assigned, we rollback by clearing the work assignment. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
851 lines
21 KiB
Go
851 lines
21 KiB
Go
package cmd
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/spf13/cobra"
|
|
"github.com/steveyegge/gastown/internal/beads"
|
|
"github.com/steveyegge/gastown/internal/config"
|
|
"github.com/steveyegge/gastown/internal/dog"
|
|
"github.com/steveyegge/gastown/internal/mail"
|
|
"github.com/steveyegge/gastown/internal/plugin"
|
|
"github.com/steveyegge/gastown/internal/style"
|
|
"github.com/steveyegge/gastown/internal/tmux"
|
|
"github.com/steveyegge/gastown/internal/workspace"
|
|
)
|
|
|
|
// Dog command flags
|
|
var (
|
|
dogListJSON bool
|
|
dogStatusJSON bool
|
|
dogForce bool
|
|
dogRemoveAll bool
|
|
dogCallAll bool
|
|
|
|
// Dispatch flags
|
|
dogDispatchPlugin string
|
|
dogDispatchRig string
|
|
dogDispatchCreate bool
|
|
dogDispatchDog string
|
|
dogDispatchJSON bool
|
|
dogDispatchDryRun bool
|
|
)
|
|
|
|
var dogCmd = &cobra.Command{
|
|
Use: "dog",
|
|
Aliases: []string{"dogs"},
|
|
GroupID: GroupAgents,
|
|
Short: "Manage dogs (Deacon's helper workers)",
|
|
Long: `Manage dogs in the kennel.
|
|
|
|
Dogs are reusable helper workers managed by the Deacon for infrastructure
|
|
and cleanup tasks. Unlike polecats (single-rig, ephemeral), dogs handle
|
|
cross-rig infrastructure work with worktrees into each rig.
|
|
|
|
The kennel is located at ~/gt/deacon/dogs/.`,
|
|
}
|
|
|
|
var dogAddCmd = &cobra.Command{
|
|
Use: "add <name>",
|
|
Short: "Create a new dog in the kennel",
|
|
Long: `Create a new dog in the kennel with multi-rig worktrees.
|
|
|
|
Each dog gets a worktree per configured rig (e.g., gastown, beads).
|
|
The dog starts in idle state, ready to receive work from the Deacon.
|
|
|
|
Example:
|
|
gt dog add alpha
|
|
gt dog add bravo`,
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: runDogAdd,
|
|
}
|
|
|
|
var dogRemoveCmd = &cobra.Command{
|
|
Use: "remove <name>... | --all",
|
|
Short: "Remove dogs from the kennel",
|
|
Long: `Remove one or more dogs from the kennel.
|
|
|
|
Removes all worktrees and the dog directory.
|
|
Use --force to remove even if dog is in working state.
|
|
|
|
Examples:
|
|
gt dog remove alpha
|
|
gt dog remove alpha bravo
|
|
gt dog remove --all
|
|
gt dog remove alpha --force`,
|
|
Args: func(cmd *cobra.Command, args []string) error {
|
|
if dogRemoveAll {
|
|
return nil
|
|
}
|
|
if len(args) < 1 {
|
|
return fmt.Errorf("requires at least 1 dog name (or use --all)")
|
|
}
|
|
return nil
|
|
},
|
|
RunE: runDogRemove,
|
|
}
|
|
|
|
var dogListCmd = &cobra.Command{
|
|
Use: "list",
|
|
Aliases: []string{"ls"},
|
|
Short: "List all dogs in the kennel",
|
|
Long: `List all dogs in the kennel with their status.
|
|
|
|
Shows each dog's state (idle/working), current work assignment,
|
|
and last active timestamp.
|
|
|
|
Examples:
|
|
gt dog list
|
|
gt dog list --json`,
|
|
RunE: runDogList,
|
|
}
|
|
|
|
var dogCallCmd = &cobra.Command{
|
|
Use: "call [name]",
|
|
Short: "Wake idle dog(s) for work",
|
|
Long: `Wake an idle dog to prepare for work.
|
|
|
|
With a name, wakes the specific dog.
|
|
With --all, wakes all idle dogs.
|
|
Without arguments, wakes one idle dog (if available).
|
|
|
|
This updates the dog's last-active timestamp and can trigger
|
|
session creation for the dog's worktrees.
|
|
|
|
Examples:
|
|
gt dog call alpha
|
|
gt dog call --all
|
|
gt dog call`,
|
|
RunE: runDogCall,
|
|
}
|
|
|
|
var dogStatusCmd = &cobra.Command{
|
|
Use: "status [name]",
|
|
Short: "Show detailed dog status",
|
|
Long: `Show detailed status for a specific dog or summary for all dogs.
|
|
|
|
With a name, shows detailed info including:
|
|
- State (idle/working)
|
|
- Current work assignment
|
|
- Worktree paths per rig
|
|
- Last active timestamp
|
|
|
|
Without a name, shows pack summary:
|
|
- Total dogs
|
|
- Idle/working counts
|
|
- Pack health
|
|
|
|
Examples:
|
|
gt dog status alpha
|
|
gt dog status
|
|
gt dog status --json`,
|
|
RunE: runDogStatus,
|
|
}
|
|
|
|
var dogDispatchCmd = &cobra.Command{
|
|
Use: "dispatch --plugin <name>",
|
|
Short: "Dispatch plugin execution to a dog",
|
|
Long: `Dispatch a plugin for execution by a dog worker.
|
|
|
|
This is the formalized command for sending plugin work to dogs. The Deacon
|
|
uses this during patrol cycles to dispatch plugins with open gates.
|
|
|
|
The command:
|
|
1. Finds the plugin definition (plugin.md)
|
|
2. Assigns work to an idle dog (marks as working)
|
|
3. Sends mail with plugin instructions to the dog
|
|
4. Returns immediately (non-blocking)
|
|
|
|
The dog discovers the work via its mail inbox and executes the plugin
|
|
instructions. On completion, the dog sends DOG_DONE mail to deacon/.
|
|
|
|
Examples:
|
|
gt dog dispatch --plugin rebuild-gt
|
|
gt dog dispatch --plugin rebuild-gt --rig gastown
|
|
gt dog dispatch --plugin rebuild-gt --dog alpha
|
|
gt dog dispatch --plugin rebuild-gt --create
|
|
gt dog dispatch --plugin rebuild-gt --dry-run
|
|
gt dog dispatch --plugin rebuild-gt --json`,
|
|
RunE: runDogDispatch,
|
|
}
|
|
|
|
func init() {
|
|
// List flags
|
|
dogListCmd.Flags().BoolVar(&dogListJSON, "json", false, "Output as JSON")
|
|
|
|
// Remove flags
|
|
dogRemoveCmd.Flags().BoolVarP(&dogForce, "force", "f", false, "Force removal even if working")
|
|
dogRemoveCmd.Flags().BoolVar(&dogRemoveAll, "all", false, "Remove all dogs")
|
|
|
|
// Call flags
|
|
dogCallCmd.Flags().BoolVar(&dogCallAll, "all", false, "Wake all idle dogs")
|
|
|
|
// Status flags
|
|
dogStatusCmd.Flags().BoolVar(&dogStatusJSON, "json", false, "Output as JSON")
|
|
|
|
// Dispatch flags
|
|
dogDispatchCmd.Flags().StringVar(&dogDispatchPlugin, "plugin", "", "Plugin name to dispatch (required)")
|
|
dogDispatchCmd.Flags().StringVar(&dogDispatchRig, "rig", "", "Limit plugin search to specific rig")
|
|
dogDispatchCmd.Flags().StringVar(&dogDispatchDog, "dog", "", "Dispatch to specific dog (default: any idle)")
|
|
dogDispatchCmd.Flags().BoolVar(&dogDispatchCreate, "create", false, "Create a dog if none idle")
|
|
dogDispatchCmd.Flags().BoolVar(&dogDispatchJSON, "json", false, "Output as JSON")
|
|
dogDispatchCmd.Flags().BoolVarP(&dogDispatchDryRun, "dry-run", "n", false, "Show what would be done without doing it")
|
|
_ = dogDispatchCmd.MarkFlagRequired("plugin")
|
|
|
|
// Add subcommands
|
|
dogCmd.AddCommand(dogAddCmd)
|
|
dogCmd.AddCommand(dogRemoveCmd)
|
|
dogCmd.AddCommand(dogListCmd)
|
|
dogCmd.AddCommand(dogCallCmd)
|
|
dogCmd.AddCommand(dogStatusCmd)
|
|
dogCmd.AddCommand(dogDispatchCmd)
|
|
|
|
rootCmd.AddCommand(dogCmd)
|
|
}
|
|
|
|
// getDogManager creates a dog.Manager with the current town root.
|
|
func getDogManager() (*dog.Manager, error) {
|
|
townRoot, err := workspace.FindFromCwd()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("finding town root: %w", err)
|
|
}
|
|
|
|
rigsConfigPath := filepath.Join(townRoot, "mayor", "rigs.json")
|
|
rigsConfig, err := config.LoadRigsConfig(rigsConfigPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("loading rigs config: %w", err)
|
|
}
|
|
|
|
return dog.NewManager(townRoot, rigsConfig), nil
|
|
}
|
|
|
|
func runDogAdd(cmd *cobra.Command, args []string) error {
|
|
name := args[0]
|
|
|
|
// Validate name
|
|
if strings.ContainsAny(name, "/\\. ") {
|
|
return fmt.Errorf("dog name cannot contain /, \\, ., or spaces")
|
|
}
|
|
|
|
mgr, err := getDogManager()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
d, err := mgr.Add(name)
|
|
if err != nil {
|
|
return fmt.Errorf("adding dog %s: %w", name, err)
|
|
}
|
|
|
|
fmt.Printf("✓ Created dog %s in kennel\n", style.Bold.Render(name))
|
|
fmt.Printf(" Path: %s\n", d.Path)
|
|
fmt.Printf(" Worktrees:\n")
|
|
for rigName, path := range d.Worktrees {
|
|
fmt.Printf(" %s: %s\n", rigName, path)
|
|
}
|
|
|
|
// Create agent bead for the dog
|
|
townRoot, _ := workspace.FindFromCwd()
|
|
if townRoot != "" {
|
|
b := beads.New(townRoot)
|
|
location := filepath.Join("deacon", "dogs", name)
|
|
|
|
issue, err := b.CreateDogAgentBead(name, location)
|
|
if err != nil {
|
|
// Non-fatal: warn but don't fail dog creation
|
|
fmt.Printf(" Warning: could not create agent bead: %v\n", err)
|
|
} else {
|
|
fmt.Printf(" Agent bead: %s\n", issue.ID)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func runDogRemove(cmd *cobra.Command, args []string) error {
|
|
mgr, err := getDogManager()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var names []string
|
|
if dogRemoveAll {
|
|
dogs, err := mgr.List()
|
|
if err != nil {
|
|
return fmt.Errorf("listing dogs: %w", err)
|
|
}
|
|
for _, d := range dogs {
|
|
names = append(names, d.Name)
|
|
}
|
|
if len(names) == 0 {
|
|
fmt.Println("No dogs in kennel")
|
|
return nil
|
|
}
|
|
} else {
|
|
names = args
|
|
}
|
|
|
|
// Get beads client for cleanup
|
|
townRoot, _ := workspace.FindFromCwd()
|
|
var b *beads.Beads
|
|
if townRoot != "" {
|
|
b = beads.New(townRoot)
|
|
}
|
|
|
|
for _, name := range names {
|
|
d, err := mgr.Get(name)
|
|
if err != nil {
|
|
fmt.Printf("Warning: dog %s not found, skipping\n", name)
|
|
continue
|
|
}
|
|
|
|
// Check if working
|
|
if d.State == dog.StateWorking && !dogForce {
|
|
return fmt.Errorf("dog %s is working (use --force to remove anyway)", name)
|
|
}
|
|
|
|
if err := mgr.Remove(name); err != nil {
|
|
return fmt.Errorf("removing dog %s: %w", name, err)
|
|
}
|
|
|
|
fmt.Printf("✓ Removed dog %s\n", name)
|
|
|
|
// Delete agent bead for the dog
|
|
if b != nil {
|
|
if err := b.DeleteDogAgentBead(name); err != nil {
|
|
// Non-fatal: warn but don't fail dog removal
|
|
fmt.Printf(" Warning: could not delete agent bead: %v\n", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func runDogList(cmd *cobra.Command, args []string) error {
|
|
mgr, err := getDogManager()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
dogs, err := mgr.List()
|
|
if err != nil {
|
|
return fmt.Errorf("listing dogs: %w", err)
|
|
}
|
|
|
|
if len(dogs) == 0 {
|
|
if dogListJSON {
|
|
fmt.Println("[]")
|
|
} else {
|
|
fmt.Println("No dogs in kennel")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
if dogListJSON {
|
|
type DogListItem struct {
|
|
Name string `json:"name"`
|
|
State dog.State `json:"state"`
|
|
Work string `json:"work,omitempty"`
|
|
LastActive time.Time `json:"last_active"`
|
|
Worktrees map[string]string `json:"worktrees,omitempty"`
|
|
}
|
|
|
|
var items []DogListItem
|
|
for _, d := range dogs {
|
|
items = append(items, DogListItem{
|
|
Name: d.Name,
|
|
State: d.State,
|
|
Work: d.Work,
|
|
LastActive: d.LastActive,
|
|
Worktrees: d.Worktrees,
|
|
})
|
|
}
|
|
|
|
enc := json.NewEncoder(os.Stdout)
|
|
enc.SetIndent("", " ")
|
|
return enc.Encode(items)
|
|
}
|
|
|
|
// Pretty print
|
|
fmt.Println(style.Bold.Render("The Pack"))
|
|
fmt.Println()
|
|
|
|
idleCount := 0
|
|
workingCount := 0
|
|
|
|
for _, d := range dogs {
|
|
stateIcon := "○"
|
|
stateStyle := style.Dim
|
|
if d.State == dog.StateWorking {
|
|
stateIcon = "●"
|
|
stateStyle = style.Bold
|
|
workingCount++
|
|
} else {
|
|
idleCount++
|
|
}
|
|
|
|
line := fmt.Sprintf(" %s %s", stateIcon, stateStyle.Render(d.Name))
|
|
if d.Work != "" {
|
|
line += fmt.Sprintf(" → %s", style.Dim.Render(d.Work))
|
|
}
|
|
fmt.Println(line)
|
|
}
|
|
|
|
fmt.Println()
|
|
fmt.Printf(" %d idle, %d working\n", idleCount, workingCount)
|
|
|
|
return nil
|
|
}
|
|
|
|
func runDogCall(cmd *cobra.Command, args []string) error {
|
|
mgr, err := getDogManager()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if dogCallAll {
|
|
// Wake all idle dogs
|
|
dogs, err := mgr.List()
|
|
if err != nil {
|
|
return fmt.Errorf("listing dogs: %w", err)
|
|
}
|
|
|
|
woken := 0
|
|
for _, d := range dogs {
|
|
if d.State == dog.StateIdle {
|
|
if err := mgr.SetState(d.Name, dog.StateIdle); err != nil {
|
|
fmt.Printf("Warning: failed to wake %s: %v\n", d.Name, err)
|
|
continue
|
|
}
|
|
woken++
|
|
fmt.Printf("✓ Called %s\n", d.Name)
|
|
}
|
|
}
|
|
|
|
if woken == 0 {
|
|
fmt.Println("No idle dogs to call")
|
|
} else {
|
|
fmt.Printf("\n%d dog(s) ready\n", woken)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
if len(args) > 0 {
|
|
// Wake specific dog
|
|
name := args[0]
|
|
d, err := mgr.Get(name)
|
|
if err != nil {
|
|
return fmt.Errorf("getting dog %s: %w", name, err)
|
|
}
|
|
|
|
if d.State == dog.StateWorking {
|
|
fmt.Printf("Dog %s is already working\n", name)
|
|
return nil
|
|
}
|
|
|
|
if err := mgr.SetState(name, dog.StateIdle); err != nil {
|
|
return fmt.Errorf("waking dog %s: %w", name, err)
|
|
}
|
|
|
|
fmt.Printf("✓ Called %s - ready for work\n", name)
|
|
return nil
|
|
}
|
|
|
|
// Wake one idle dog
|
|
d, err := mgr.GetIdleDog()
|
|
if err != nil {
|
|
return fmt.Errorf("getting idle dog: %w", err)
|
|
}
|
|
|
|
if d == nil {
|
|
fmt.Println("No idle dogs available")
|
|
return nil
|
|
}
|
|
|
|
if err := mgr.SetState(d.Name, dog.StateIdle); err != nil {
|
|
return fmt.Errorf("waking dog %s: %w", d.Name, err)
|
|
}
|
|
|
|
fmt.Printf("✓ Called %s - ready for work\n", d.Name)
|
|
return nil
|
|
}
|
|
|
|
func runDogStatus(cmd *cobra.Command, args []string) error {
|
|
mgr, err := getDogManager()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if len(args) > 0 {
|
|
// Show specific dog status
|
|
name := args[0]
|
|
return showDogStatus(mgr, name)
|
|
}
|
|
|
|
// Show pack summary
|
|
return showPackStatus(mgr)
|
|
}
|
|
|
|
func showDogStatus(mgr *dog.Manager, name string) error {
|
|
d, err := mgr.Get(name)
|
|
if err != nil {
|
|
return fmt.Errorf("getting dog %s: %w", name, err)
|
|
}
|
|
|
|
if dogStatusJSON {
|
|
enc := json.NewEncoder(os.Stdout)
|
|
enc.SetIndent("", " ")
|
|
return enc.Encode(d)
|
|
}
|
|
|
|
fmt.Printf("Dog: %s\n\n", style.Bold.Render(d.Name))
|
|
fmt.Printf(" State: %s\n", d.State)
|
|
if d.Work != "" {
|
|
fmt.Printf(" Work: %s\n", d.Work)
|
|
} else {
|
|
fmt.Printf(" Work: %s\n", style.Dim.Render("(none)"))
|
|
}
|
|
fmt.Printf(" Path: %s\n", d.Path)
|
|
fmt.Printf(" Last Active: %s\n", dogFormatTimeAgo(d.LastActive))
|
|
fmt.Printf(" Created: %s\n", d.CreatedAt.Format("2006-01-02 15:04"))
|
|
|
|
if len(d.Worktrees) > 0 {
|
|
fmt.Println("\nWorktrees:")
|
|
for rigName, path := range d.Worktrees {
|
|
// Check if worktree exists
|
|
exists := "✓"
|
|
if _, err := os.Stat(path); os.IsNotExist(err) {
|
|
exists = "✗"
|
|
}
|
|
fmt.Printf(" %s %s: %s\n", exists, rigName, path)
|
|
}
|
|
}
|
|
|
|
// Check for tmux session
|
|
townRoot, _ := workspace.FindFromCwd()
|
|
if townRoot != "" {
|
|
townName, err := workspace.GetTownName(townRoot)
|
|
if err == nil {
|
|
sessionName := fmt.Sprintf("gt-%s-deacon-%s", townName, name)
|
|
tm := tmux.NewTmux()
|
|
if has, _ := tm.HasSession(sessionName); has {
|
|
fmt.Printf("\nSession: %s (running)\n", sessionName)
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func showPackStatus(mgr *dog.Manager) error {
|
|
dogs, err := mgr.List()
|
|
if err != nil {
|
|
return fmt.Errorf("listing dogs: %w", err)
|
|
}
|
|
|
|
if dogStatusJSON {
|
|
type PackStatus struct {
|
|
Total int `json:"total"`
|
|
Idle int `json:"idle"`
|
|
Working int `json:"working"`
|
|
KennelDir string `json:"kennel_dir"`
|
|
}
|
|
|
|
townRoot, _ := workspace.FindFromCwd()
|
|
status := PackStatus{
|
|
Total: len(dogs),
|
|
KennelDir: filepath.Join(townRoot, "deacon", "dogs"),
|
|
}
|
|
for _, d := range dogs {
|
|
if d.State == dog.StateIdle {
|
|
status.Idle++
|
|
} else {
|
|
status.Working++
|
|
}
|
|
}
|
|
|
|
enc := json.NewEncoder(os.Stdout)
|
|
enc.SetIndent("", " ")
|
|
return enc.Encode(status)
|
|
}
|
|
|
|
fmt.Println(style.Bold.Render("Pack Status"))
|
|
fmt.Println()
|
|
|
|
if len(dogs) == 0 {
|
|
fmt.Println(" No dogs in kennel")
|
|
fmt.Println()
|
|
fmt.Println(" Use 'gt dog add <name>' to add a dog")
|
|
return nil
|
|
}
|
|
|
|
idleCount := 0
|
|
workingCount := 0
|
|
for _, d := range dogs {
|
|
if d.State == dog.StateIdle {
|
|
idleCount++
|
|
} else {
|
|
workingCount++
|
|
}
|
|
}
|
|
|
|
fmt.Printf(" Total: %d\n", len(dogs))
|
|
fmt.Printf(" Idle: %d\n", idleCount)
|
|
fmt.Printf(" Working: %d\n", workingCount)
|
|
|
|
if idleCount > 0 {
|
|
fmt.Println()
|
|
fmt.Println(style.Dim.Render(" Ready for work. Use 'gt dog call' to wake."))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// dogFormatTimeAgo formats a time as a relative string like "2 hours ago".
|
|
func dogFormatTimeAgo(t time.Time) string {
|
|
if t.IsZero() {
|
|
return "(unknown)"
|
|
}
|
|
|
|
d := time.Since(t)
|
|
switch {
|
|
case d < time.Minute:
|
|
return "just now"
|
|
case d < time.Hour:
|
|
mins := int(d.Minutes())
|
|
if mins == 1 {
|
|
return "1 minute ago"
|
|
}
|
|
return fmt.Sprintf("%d minutes ago", mins)
|
|
case d < 24*time.Hour:
|
|
hours := int(d.Hours())
|
|
if hours == 1 {
|
|
return "1 hour ago"
|
|
}
|
|
return fmt.Sprintf("%d hours ago", hours)
|
|
default:
|
|
days := int(d.Hours() / 24)
|
|
if days == 1 {
|
|
return "1 day ago"
|
|
}
|
|
return fmt.Sprintf("%d days ago", days)
|
|
}
|
|
}
|
|
|
|
// runDogDispatch dispatches plugin execution to a dog worker.
|
|
func runDogDispatch(cmd *cobra.Command, args []string) error {
|
|
townRoot, err := workspace.FindFromCwd()
|
|
if err != nil {
|
|
return fmt.Errorf("finding town root: %w", err)
|
|
}
|
|
|
|
// Get rig names for plugin scanner
|
|
rigsConfigPath := filepath.Join(townRoot, "mayor", "rigs.json")
|
|
rigsConfig, err := config.LoadRigsConfig(rigsConfigPath)
|
|
if err != nil {
|
|
return fmt.Errorf("loading rigs config: %w", err)
|
|
}
|
|
|
|
var rigNames []string
|
|
for rigName := range rigsConfig.Rigs {
|
|
rigNames = append(rigNames, rigName)
|
|
}
|
|
|
|
// If --rig specified, search only that rig
|
|
if dogDispatchRig != "" {
|
|
rigNames = []string{dogDispatchRig}
|
|
}
|
|
|
|
// Find the plugin using scanner
|
|
scanner := plugin.NewScanner(townRoot, rigNames)
|
|
p, err := scanner.GetPlugin(dogDispatchPlugin)
|
|
if err != nil {
|
|
return fmt.Errorf("finding plugin: %w", err)
|
|
}
|
|
|
|
// Get dog manager (reuse rigsConfig from above)
|
|
mgr := dog.NewManager(townRoot, rigsConfig)
|
|
|
|
// Find target dog
|
|
var targetDog *dog.Dog
|
|
var dogCreated bool
|
|
if dogDispatchDog != "" {
|
|
// Specific dog requested
|
|
targetDog, err = mgr.Get(dogDispatchDog)
|
|
if err != nil {
|
|
return fmt.Errorf("getting dog %s: %w", dogDispatchDog, err)
|
|
}
|
|
if targetDog.State == dog.StateWorking {
|
|
return fmt.Errorf("dog %s is already working", dogDispatchDog)
|
|
}
|
|
} else {
|
|
// Find idle dog from pool
|
|
targetDog, err = mgr.GetIdleDog()
|
|
if err != nil {
|
|
return fmt.Errorf("finding idle dog: %w", err)
|
|
}
|
|
|
|
if targetDog == nil {
|
|
if dogDispatchCreate {
|
|
// Create a new dog (reuse generateDogName from sling_dog.go)
|
|
newName := generateDogName(mgr)
|
|
if dogDispatchDryRun {
|
|
targetDog = &dog.Dog{Name: newName, State: dog.StateIdle}
|
|
dogCreated = true
|
|
} else {
|
|
targetDog, err = mgr.Add(newName)
|
|
if err != nil {
|
|
return fmt.Errorf("creating dog %s: %w", newName, err)
|
|
}
|
|
dogCreated = true
|
|
|
|
// Create agent bead for the dog
|
|
b := beads.New(townRoot)
|
|
location := filepath.Join("deacon", "dogs", newName)
|
|
if _, beadErr := b.CreateDogAgentBead(newName, location); beadErr != nil {
|
|
// Non-fatal warning
|
|
if !dogDispatchJSON {
|
|
fmt.Printf(" Warning: could not create agent bead: %v\n", beadErr)
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
return fmt.Errorf("no idle dogs available (use --create to add one)")
|
|
}
|
|
}
|
|
}
|
|
|
|
// Prepare dispatch result for JSON output
|
|
workDesc := fmt.Sprintf("plugin:%s", p.Name)
|
|
result := dogDispatchResult{
|
|
Plugin: p.Name,
|
|
PluginPath: p.Path,
|
|
Dog: targetDog.Name,
|
|
DogCreated: dogCreated,
|
|
Work: workDesc,
|
|
DryRun: dogDispatchDryRun,
|
|
}
|
|
if p.RigName != "" {
|
|
result.PluginRig = p.RigName
|
|
}
|
|
|
|
// Dry-run mode: show what would happen and exit
|
|
if dogDispatchDryRun {
|
|
if dogDispatchJSON {
|
|
return json.NewEncoder(os.Stdout).Encode(result)
|
|
}
|
|
fmt.Printf("Dry run - would dispatch:\n")
|
|
fmt.Printf(" Plugin: %s\n", p.Name)
|
|
if p.RigName != "" {
|
|
fmt.Printf(" Location: %s/plugins/%s\n", p.RigName, p.Name)
|
|
} else {
|
|
fmt.Printf(" Location: plugins/%s (town-level)\n", p.Name)
|
|
}
|
|
fmt.Printf(" Dog: %s%s\n", targetDog.Name, ifStr(dogCreated, " (would create)", ""))
|
|
fmt.Printf(" Work: %s\n", workDesc)
|
|
return nil
|
|
}
|
|
|
|
// Assign work FIRST (before sending mail) to prevent race condition
|
|
// If this fails, we haven't sent any mail yet
|
|
if err := mgr.AssignWork(targetDog.Name, workDesc); err != nil {
|
|
return fmt.Errorf("assigning work to dog: %w", err)
|
|
}
|
|
|
|
// Create and send mail message with plugin instructions
|
|
dogAddress := fmt.Sprintf("deacon/dogs/%s", targetDog.Name)
|
|
subject := fmt.Sprintf("Plugin: %s", p.Name)
|
|
body := formatPluginMailBody(p)
|
|
|
|
router := mail.NewRouterWithTownRoot(townRoot, townRoot)
|
|
msg := &mail.Message{
|
|
From: "deacon/",
|
|
To: dogAddress,
|
|
Subject: subject,
|
|
Body: body,
|
|
Timestamp: time.Now(),
|
|
}
|
|
|
|
if err := router.Send(msg); err != nil {
|
|
// Rollback: clear work assignment since mail failed
|
|
if clearErr := mgr.ClearWork(targetDog.Name); clearErr != nil {
|
|
// Log rollback failure but return original error
|
|
if !dogDispatchJSON {
|
|
fmt.Printf(" Warning: rollback failed: %v\n", clearErr)
|
|
}
|
|
}
|
|
return fmt.Errorf("sending plugin mail to dog: %w", err)
|
|
}
|
|
|
|
// Success - output result
|
|
if dogDispatchJSON {
|
|
return json.NewEncoder(os.Stdout).Encode(result)
|
|
}
|
|
|
|
fmt.Printf("%s Found plugin: %s\n", style.Bold.Render("✓"), p.Name)
|
|
if p.RigName != "" {
|
|
fmt.Printf(" Location: %s/plugins/%s\n", p.RigName, p.Name)
|
|
} else {
|
|
fmt.Printf(" Location: plugins/%s (town-level)\n", p.Name)
|
|
}
|
|
if dogCreated {
|
|
fmt.Printf("%s Created dog %s (pool was empty)\n", style.Bold.Render("✓"), targetDog.Name)
|
|
}
|
|
fmt.Printf("%s Dispatching to dog: %s\n", style.Bold.Render("🐕"), targetDog.Name)
|
|
fmt.Printf("%s Plugin dispatched (non-blocking)\n", style.Bold.Render("✓"))
|
|
fmt.Printf(" Dog: %s\n", targetDog.Name)
|
|
fmt.Printf(" Work: %s\n", workDesc)
|
|
|
|
return nil
|
|
}
|
|
|
|
// dogDispatchResult is the JSON output for gt dog dispatch.
|
|
type dogDispatchResult struct {
|
|
Plugin string `json:"plugin"`
|
|
PluginRig string `json:"plugin_rig,omitempty"`
|
|
PluginPath string `json:"plugin_path"`
|
|
Dog string `json:"dog"`
|
|
DogCreated bool `json:"dog_created,omitempty"`
|
|
Work string `json:"work"`
|
|
DryRun bool `json:"dry_run,omitempty"`
|
|
}
|
|
|
|
// ifStr returns ifTrue if cond is true, otherwise ifFalse.
|
|
func ifStr(cond bool, ifTrue, ifFalse string) string {
|
|
if cond {
|
|
return ifTrue
|
|
}
|
|
return ifFalse
|
|
}
|
|
|
|
// formatPluginMailBody formats the plugin as instructions for the dog.
|
|
func formatPluginMailBody(p *plugin.Plugin) string {
|
|
var sb strings.Builder
|
|
|
|
sb.WriteString("Execute the following plugin:\n\n")
|
|
sb.WriteString(fmt.Sprintf("**Plugin**: %s\n", p.Name))
|
|
sb.WriteString(fmt.Sprintf("**Description**: %s\n", p.Description))
|
|
if p.RigName != "" {
|
|
sb.WriteString(fmt.Sprintf("**Rig**: %s\n", p.RigName))
|
|
}
|
|
if p.Execution != nil && p.Execution.Timeout != "" {
|
|
sb.WriteString(fmt.Sprintf("**Timeout**: %s\n", p.Execution.Timeout))
|
|
}
|
|
sb.WriteString("\n---\n\n")
|
|
sb.WriteString("## Instructions\n\n")
|
|
sb.WriteString(p.Instructions)
|
|
sb.WriteString("\n\n---\n\n")
|
|
sb.WriteString("After completion:\n")
|
|
sb.WriteString("1. Create a wisp to record the result (success/failure)\n")
|
|
sb.WriteString("2. Send DOG_DONE mail to deacon/\n")
|
|
sb.WriteString("3. Return to idle state\n")
|
|
|
|
return sb.String()
|
|
}
|