From 46868a5bab609b899876cc7462bd6d75088b8e79 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Tue, 30 Dec 2025 00:35:21 -0800 Subject: [PATCH] fix: Implement gt swarm dispatch command (gt-s94gq) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dispatch command was documented in help text but never implemented. Now it: - Finds unassigned ready tasks in an epic - Locates idle polecats (no hooked work) - Slings the first available task to the first idle worker 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/cmd/swarm.go | 156 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 156 insertions(+) diff --git a/internal/cmd/swarm.go b/internal/cmd/swarm.go index e5766bb6..62106c1f 100644 --- a/internal/cmd/swarm.go +++ b/internal/cmd/swarm.go @@ -151,6 +151,23 @@ Transitions the swarm from 'created' to 'active' state.`, RunE: runSwarmStart, } +var swarmDispatchCmd = &cobra.Command{ + Use: "dispatch ", + Short: "Assign next ready task to an idle worker", + Long: `Dispatch the next ready task from an epic to an available worker. + +Finds the first unassigned task in the epic's ready front and slings it +to an idle polecat in the rig. + +Examples: + gt swarm dispatch gt-abc # Dispatch next task from epic gt-abc + gt swarm dispatch gt-abc --rig gastown # Dispatch in specific rig`, + Args: cobra.ExactArgs(1), + RunE: runSwarmDispatch, +} + +var swarmDispatchRig string + func init() { // Create flags swarmCreateCmd.Flags().StringVar(&swarmEpic, "epic", "", "Beads epic ID for this swarm (required)") @@ -166,6 +183,9 @@ func init() { swarmListCmd.Flags().StringVar(&swarmListStatus, "status", "", "Filter by status (active, landed, cancelled, failed)") swarmListCmd.Flags().BoolVar(&swarmListJSON, "json", false, "Output as JSON") + // Dispatch flags + swarmDispatchCmd.Flags().StringVar(&swarmDispatchRig, "rig", "", "Rig to dispatch in (auto-detected from epic if not specified)") + // Add subcommands swarmCmd.AddCommand(swarmCreateCmd) swarmCmd.AddCommand(swarmStartCmd) @@ -173,6 +193,7 @@ func init() { swarmCmd.AddCommand(swarmListCmd) swarmCmd.AddCommand(swarmLandCmd) swarmCmd.AddCommand(swarmCancelCmd) + swarmCmd.AddCommand(swarmDispatchCmd) rootCmd.AddCommand(swarmCmd) } @@ -396,6 +417,141 @@ func runSwarmStart(cmd *cobra.Command, args []string) error { return nil } +func runSwarmDispatch(cmd *cobra.Command, args []string) error { + epicID := args[0] + + // Find the epic's rig by trying to show it in each rig + rigs, townRoot, err := getAllRigs() + if err != nil { + return err + } + + var foundRig *rig.Rig + for _, r := range rigs { + // If --rig specified, only check that rig + if swarmDispatchRig != "" && r.Name != swarmDispatchRig { + continue + } + // Use BeadsPath() to ensure we read from git-synced location + checkCmd := exec.Command("bd", "show", epicID, "--json") + checkCmd.Dir = r.BeadsPath() + if err := checkCmd.Run(); err == nil { + foundRig = r + break + } + } + + if foundRig == nil { + if swarmDispatchRig != "" { + return fmt.Errorf("epic '%s' not found in rig '%s'", epicID, swarmDispatchRig) + } + return fmt.Errorf("epic '%s' not found in any rig", epicID) + } + + // Get swarm/epic status to find ready tasks + statusCmd := exec.Command("bd", "swarm", "status", epicID, "--json") + statusCmd.Dir = foundRig.BeadsPath() + var stdout bytes.Buffer + statusCmd.Stdout = &stdout + + if err := statusCmd.Run(); err != nil { + return fmt.Errorf("getting epic status: %w", err) + } + + var status struct { + Ready []struct { + ID string `json:"id"` + Title string `json:"title"` + Assignee string `json:"assignee"` + } `json:"ready"` + } + if err := json.Unmarshal(stdout.Bytes(), &status); err != nil { + return fmt.Errorf("parsing epic status: %w", err) + } + + // Filter to unassigned ready tasks + var unassigned []struct { + ID string + Title string + } + for _, task := range status.Ready { + if task.Assignee == "" { + unassigned = append(unassigned, struct { + ID string + Title string + }{task.ID, task.Title}) + } + } + + if len(unassigned) == 0 { + fmt.Println("No unassigned ready tasks to dispatch") + return nil + } + + // Find idle polecats (no hooked work) + polecatGit := git.NewGit(foundRig.Path) + polecatMgr := polecat.NewManager(foundRig, polecatGit) + polecats, err := polecatMgr.List() + if err != nil { + return fmt.Errorf("listing polecats: %w", err) + } + + // Check which polecats have no hooked work + var idlePolecats []string + for _, p := range polecats { + // Check if polecat has hooked work by querying beads + hookCheckCmd := exec.Command("bd", "list", "--status=hooked", "--assignee", fmt.Sprintf("%s/polecats/%s", foundRig.Name, p.Name), "--json") + hookCheckCmd.Dir = foundRig.BeadsPath() + var hookOut bytes.Buffer + hookCheckCmd.Stdout = &hookOut + if err := hookCheckCmd.Run(); err == nil { + var hooked []interface{} + if err := json.Unmarshal(hookOut.Bytes(), &hooked); err == nil && len(hooked) == 0 { + idlePolecats = append(idlePolecats, p.Name) + } + } + } + + if len(idlePolecats) == 0 { + fmt.Println("No idle polecats available") + fmt.Printf("\nUnassigned ready tasks:\n") + for _, task := range unassigned { + fmt.Printf(" ○ %s: %s\n", task.ID, task.Title) + } + fmt.Printf("\nCreate a new polecat or wait for one to become idle.\n") + return nil + } + + // Dispatch first unassigned task to first idle polecat + task := unassigned[0] + worker := idlePolecats[0] + target := fmt.Sprintf("%s/%s", foundRig.Name, worker) + + fmt.Printf("Dispatching %s to %s...\n", task.ID, target) + + // Use gt sling to assign the task + slingCmd := exec.Command("gt", "sling", task.ID, target) + slingCmd.Dir = townRoot + slingCmd.Stdout = os.Stdout + slingCmd.Stderr = os.Stderr + + if err := slingCmd.Run(); err != nil { + return fmt.Errorf("slinging task: %w", err) + } + + fmt.Printf("%s Dispatched %s: %s → %s\n", style.Bold.Render("✓"), task.ID, task.Title, target) + + // Show remaining tasks and workers + if len(unassigned) > 1 { + fmt.Printf("\n%d more ready tasks available\n", len(unassigned)-1) + } + if len(idlePolecats) > 1 { + fmt.Printf("%d more idle polecats available\n", len(idlePolecats)-1) + } + + return nil +} + // spawnSwarmWorkersFromBeads spawns sessions for swarm workers using beads task list. func spawnSwarmWorkersFromBeads(r *rig.Rig, townRoot string, swarmID string, workers []string, tasks []struct { ID string `json:"id"`