diff --git a/internal/cmd/convoy.go b/internal/cmd/convoy.go index 3bf5d381e4..31d7098c4a 100644 --- a/internal/cmd/convoy.go +++ b/internal/cmd/convoy.go @@ -372,6 +372,34 @@ Examples: RunE: runConvoyLand, } +var ( + convoyGCJSON bool +) + +var convoyGCCmd = &cobra.Command{ + Use: "gc ", + Short: "GC convoy legs with idle/done assignee polecats", + Long: `Garbage-collect convoy legs whose assignee polecat is idle, stuck, or nuked. + +When a polecat goes idle (via gt done) or gets stuck without properly closing +its convoy leg bead, the convoy gets stuck: tracked issues exist but none are +ready. This command finds and closes those legs so the convoy can progress. + +For each open tracked bead in the convoy: + 1. Look up the assignee (e.g., gastown/polecats/dag) + 2. Resolve to agent bead ID (e.g., gt-gastown-polecat-dag) + 3. If agent_state is idle, stuck, or nuked → close the leg + +Returns count of legs closed (0 if all assignees are still active). + +Examples: + gt convoy gc hq-cv-abc # GC idle legs in a convoy + gt convoy gc hq-cv-abc --json # Machine-readable output`, + Args: cobra.ExactArgs(1), + SilenceUsage: true, + RunE: runConvoyGC, +} + func init() { // Create flags convoyCreateCmd.Flags().StringVar(&convoyMolecule, "molecule", "", "Associated molecule ID") @@ -411,6 +439,9 @@ func init() { convoyLandCmd.Flags().BoolVar(&convoyLandKeep, "keep-worktrees", false, "Skip worktree cleanup") convoyLandCmd.Flags().BoolVar(&convoyLandDryRun, "dry-run", false, "Show what would happen without acting") + // GC flags + convoyGCCmd.Flags().BoolVar(&convoyGCJSON, "json", false, "Output as JSON") + // Add subcommands convoyCmd.AddCommand(convoyCreateCmd) convoyCmd.AddCommand(convoyStatusCmd) @@ -422,6 +453,7 @@ func init() { convoyCmd.AddCommand(convoyLandCmd) convoyCmd.AddCommand(convoyStageCmd) convoyCmd.AddCommand(convoyLaunchCmd) + convoyCmd.AddCommand(convoyGCCmd) rootCmd.AddCommand(convoyCmd) } @@ -1006,6 +1038,38 @@ func checkSingleConvoy(townBeads, convoyID string, dryRun bool) error { return err } +func runConvoyGC(cmd *cobra.Command, args []string) error { + convoyID := args[0] + + townRoot, err := workspace.FindFromCwdOrError() + if err != nil { + return fmt.Errorf("not in a Gas Town workspace: %w", err) + } + + ctx := cmd.Context() + logger := func(format string, args ...interface{}) { + if !convoyGCJSON { + fmt.Fprintf(os.Stderr, format+"\n", args...) + } + } + + result := convoyops.GCIdleAssigneeLegs(ctx, townRoot, convoyID, "gc", logger) + + if convoyGCJSON { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(result) + } + + if result.LegsClosed == 0 { + fmt.Printf("%s Convoy %s: no idle-assignee legs to GC\n", style.Dim.Render("○"), convoyID) + } else { + fmt.Printf("%s Convoy %s: closed %d idle-assignee leg(s)\n", style.Bold.Render("✓"), convoyID, result.LegsClosed) + } + + return nil +} + func runConvoyClose(cmd *cobra.Command, args []string) error { convoyID := args[0] diff --git a/internal/convoy/operations.go b/internal/convoy/operations.go index f402f203b3..c863a6d301 100644 --- a/internal/convoy/operations.go +++ b/internal/convoy/operations.go @@ -522,6 +522,249 @@ func fetchCrossRigBeadStatus(townRoot string, ids []string) map[string]*beadsdk. return result } +// GCIdleAssigneeResult holds the result of a GC operation. +type GCIdleAssigneeResult struct { + ConvoyID string `json:"convoy_id"` + LegsClosed int `json:"legs_closed"` + ClosedIDs []string `json:"closed_ids,omitempty"` +} + +// GCIdleAssigneeLegs finds open legs in a convoy whose assignee is a polecat +// with agent_state idle, stuck, or nuked, and closes them. Returns the result +// including count and IDs of legs closed. This unblocks convoys stuck because +// polecats went idle/done without properly closing their leg beads. +// +// Uses bd CLI commands (no store dependency) so it can be called from both +// the CLI command and the daemon (via subprocess). +func GCIdleAssigneeLegs(ctx context.Context, townRoot, convoyID, caller string, logger func(format string, args ...interface{})) GCIdleAssigneeResult { + if logger == nil { + logger = func(format string, args ...interface{}) {} + } + + result := GCIdleAssigneeResult{ConvoyID: convoyID} + + // Get tracked issues via bd dep list + tracked := getTrackedIssuesViaCLI(townRoot, convoyID) + if len(tracked) == 0 { + return result + } + + for _, issue := range tracked { + // Only look at non-closed issues with an assignee + if issue.Status == "closed" || issue.Status == "tombstone" || issue.Assignee == "" { + continue + } + + // Check if the assignee is a polecat with idle/stuck/nuked state + agentState := queryAssigneeAgentState(townRoot, issue.Assignee) + if agentState == "" { + continue // Can't determine state, skip + } + + // GC if the polecat is idle, stuck, or nuked (all indicate it's not working on this leg) + switch agentState { + case "idle", "stuck", "nuked": + reason := fmt.Sprintf("auto-gc: assignee polecat %s (agent_state=%s)", issue.Assignee, agentState) + logger("%s: convoy %s: closing leg %s — %s", caller, convoyID, issue.ID, reason) + + if err := closeTrackedLeg(ctx, townRoot, issue.ID, reason); err != nil { + logger("%s: convoy %s: failed to close leg %s: %s", caller, convoyID, issue.ID, err) + continue + } + result.LegsClosed++ + result.ClosedIDs = append(result.ClosedIDs, issue.ID) + } + } + + return result +} + +// cliTrackedIssue holds basic info from bd dep list for GC purposes. +type cliTrackedIssue struct { + ID string `json:"id"` + Status string `json:"status"` + Assignee string `json:"assignee"` +} + +// getTrackedIssuesViaCLI fetches tracked issues for a convoy using bd dep list. +func getTrackedIssuesViaCLI(townRoot, convoyID string) []cliTrackedIssue { + cmd := exec.Command("bd", "dep", "list", convoyID, "--direction=down", "--type=tracks", "--json") + cmd.Dir = townRoot + var stdout bytes.Buffer + cmd.Stdout = &stdout + + if err := cmd.Run(); err != nil { + return nil + } + + var deps []struct { + ID string `json:"id"` + Status string `json:"status"` + Assignee string `json:"assignee"` + } + if err := json.Unmarshal(stdout.Bytes(), &deps); err != nil { + return nil + } + + // Unwrap external:prefix:id and refresh status via bd show + var ids []string + for i := range deps { + deps[i].ID = extractIssueID(deps[i].ID) + ids = append(ids, deps[i].ID) + } + + // Batch-refresh status and assignee via bd show --json + if len(ids) > 0 { + freshMap := batchShowIssues(townRoot, ids) + for i := range deps { + if fresh, ok := freshMap[deps[i].ID]; ok { + deps[i].Status = fresh.Status + if fresh.Assignee != "" { + deps[i].Assignee = fresh.Assignee + } + } + } + } + + result := make([]cliTrackedIssue, len(deps)) + for i, d := range deps { + result[i] = cliTrackedIssue{ID: d.ID, Status: d.Status, Assignee: d.Assignee} + } + return result +} + +// batchShowIssues fetches fresh issue details for multiple IDs via bd show --json. +func batchShowIssues(townRoot string, ids []string) map[string]struct{ Status, Assignee string } { + result := make(map[string]struct{ Status, Assignee string }) + if len(ids) == 0 { + return result + } + + args := append([]string{"show", "--json"}, ids...) + cmd := exec.Command("bd", args...) + cmd.Dir = townRoot + var stdout bytes.Buffer + cmd.Stdout = &stdout + + if err := cmd.Run(); err != nil { + return result + } + + var issues []struct { + ID string `json:"id"` + Status string `json:"status"` + Assignee string `json:"assignee"` + } + if err := json.Unmarshal(stdout.Bytes(), &issues); err != nil { + return result + } + + for _, iss := range issues { + result[iss.ID] = struct{ Status, Assignee string }{iss.Status, iss.Assignee} + } + return result +} + +// resolvePolecatBeadID parses a mail-style assignee address and resolves it +// to the agent bead ID for a polecat. Returns ("", "", false) if the assignee +// is not a polecat address. +func resolvePolecatBeadID(townRoot, assignee string) (agentBeadID string, polecatName string, ok bool) { + parts := strings.Split(strings.TrimSuffix(assignee, "/"), "/") + if len(parts) < 2 { + return "", "", false + } + + var rig, name string + switch len(parts) { + case 2: + // Short form: rig/name — could be polecat or other role + rig = parts[0] + role := parts[1] + // Skip known singleton roles + if role == "witness" || role == "refinery" { + return "", "", false + } + name = role + case 3: + // Explicit: rig/polecats/name or rig/crew/name + rig = parts[0] + if parts[1] != "polecats" { + return "", "", false // Not a polecat + } + name = parts[2] + default: + return "", "", false + } + + // Resolve rig prefix using beads routing + prefix := beads.GetPrefixForRig(townRoot, rig) + if prefix == "" { + prefix = "gt" + } + + beadID := beads.AgentBeadIDWithPrefix(prefix, rig, "polecat", name) + return beadID, name, true +} + +// queryAssigneeAgentState resolves a mail-style assignee address to an agent bead ID +// and queries the agent bead's agent_state. Returns empty string if the assignee +// is not a polecat or the state can't be determined. +func queryAssigneeAgentState(townRoot, assignee string) string { + agentBeadID, _, ok := resolvePolecatBeadID(townRoot, assignee) + if !ok { + return "" + } + + // Query agent bead via bd show --json + cmd := exec.Command("bd", "show", agentBeadID, "--json") + cmd.Dir = townRoot + var stdout bytes.Buffer + cmd.Stdout = &stdout + + if err := cmd.Run(); err != nil { + return "" // Can't query, skip + } + + return parseAgentStateFromShowJSON(stdout.Bytes()) +} + +// parseAgentStateFromShowJSON extracts agent_state from bd show --json output. +func parseAgentStateFromShowJSON(data []byte) string { + var issues []struct { + Description string `json:"description"` + } + if err := json.Unmarshal(data, &issues); err != nil || len(issues) == 0 { + return "" + } + + for _, line := range strings.Split(issues[0].Description, "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "agent_state:") { + state := strings.TrimSpace(strings.TrimPrefix(line, "agent_state:")) + if state == "null" || state == "" { + return "" + } + return state + } + } + + return "" +} + +// closeTrackedLeg closes a convoy leg bead with a reason via bd close. +func closeTrackedLeg(ctx context.Context, townRoot, issueID, reason string) error { + cmd := exec.CommandContext(ctx, "bd", "close", issueID, "--reason="+reason, "--force") + cmd.Dir = townRoot + util.SetProcessGroup(cmd) + var stderr bytes.Buffer + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + return fmt.Errorf("%v: %s", err, strings.TrimSpace(stderr.String())) + } + return nil +} + // dispatchIssue dispatches an issue to a rig via gt sling. // The context parameter enables cancellation on daemon shutdown. // gtPath is the resolved path to the gt binary. diff --git a/internal/convoy/operations_test.go b/internal/convoy/operations_test.go index 97cbe8bc33..5067d63410 100644 --- a/internal/convoy/operations_test.go +++ b/internal/convoy/operations_test.go @@ -1684,3 +1684,149 @@ func TestFetchCrossRigBeadStatus_EmptyInput(t *testing.T) { t.Errorf("expected 0 results for empty input, got %d", len(result)) } } + +func TestParseAgentStateFromShowJSON(t *testing.T) { + tests := []struct { + name string + json string + want string + }{ + { + name: "idle state", + json: `[{"description": "Agent bead\n\nrole_type: polecat\nrig: gastown\nagent_state: idle\nhook_bead: null"}]`, + want: "idle", + }, + { + name: "stuck state", + json: `[{"description": "Agent bead\n\nagent_state: stuck"}]`, + want: "stuck", + }, + { + name: "working state", + json: `[{"description": "Agent bead\n\nagent_state: working"}]`, + want: "working", + }, + { + name: "nuked state", + json: `[{"description": "Agent bead\n\nagent_state: nuked"}]`, + want: "nuked", + }, + { + name: "null state", + json: `[{"description": "Agent bead\n\nagent_state: null"}]`, + want: "", + }, + { + name: "empty state", + json: `[{"description": "Agent bead\n\nagent_state: "}]`, + want: "", + }, + { + name: "no agent_state line", + json: `[{"description": "Just a regular bead"}]`, + want: "", + }, + { + name: "empty array", + json: `[]`, + want: "", + }, + { + name: "invalid json", + json: `not json`, + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseAgentStateFromShowJSON([]byte(tt.json)) + if got != tt.want { + t.Errorf("parseAgentStateFromShowJSON() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestResolvePolecatBeadID(t *testing.T) { + // Note: resolvePolecatBeadID calls beads.GetPrefixForRig which reads + // routes.jsonl. With a non-existent townRoot, it falls back to "gt". + townRoot := "/nonexistent" + + tests := []struct { + name string + assignee string + wantID string + wantName string + wantOK bool + }{ + { + name: "explicit polecat address", + assignee: "gastown/polecats/dag", + wantID: "gt-gastown-polecat-dag", + wantName: "dag", + wantOK: true, + }, + { + name: "short form polecat", + assignee: "gastown/nux", + wantID: "gt-gastown-polecat-nux", + wantName: "nux", + wantOK: true, + }, + { + name: "witness singleton", + assignee: "gastown/witness", + wantOK: false, + }, + { + name: "refinery singleton", + assignee: "gastown/refinery", + wantOK: false, + }, + { + name: "crew not polecat", + assignee: "gastown/crew/max", + wantOK: false, + }, + { + name: "trailing slash", + assignee: "gastown/polecats/dag/", + wantID: "gt-gastown-polecat-dag", + wantName: "dag", + wantOK: true, + }, + { + name: "empty", + assignee: "", + wantOK: false, + }, + { + name: "single part", + assignee: "gastown", + wantOK: false, + }, + { + name: "too many parts", + assignee: "gastown/polecats/dag/extra", + wantOK: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotID, gotName, gotOK := resolvePolecatBeadID(townRoot, tt.assignee) + if gotOK != tt.wantOK { + t.Errorf("ok = %v, want %v", gotOK, tt.wantOK) + } + if tt.wantOK { + if gotID != tt.wantID { + t.Errorf("agentBeadID = %q, want %q", gotID, tt.wantID) + } + if gotName != tt.wantName { + t.Errorf("polecatName = %q, want %q", gotName, tt.wantName) + } + } + }) + } +} diff --git a/internal/convoy/testmain_test.go b/internal/convoy/testmain_test.go index d5cce9b84f..5ccfaf0f10 100644 --- a/internal/convoy/testmain_test.go +++ b/internal/convoy/testmain_test.go @@ -9,6 +9,10 @@ import ( ) func TestMain(m *testing.M) { + // Clean up stale temp artifacts from previous test runs to prevent + // "no space left on device" failures on macOS. + testutil.CleanStaleTempDirs() + // Start an ephemeral Dolt container for this package's tests. // setupTestStore sets BEADS_TEST_MODE=1, which causes the beads SDK // to create testdb_ databases. By routing those to an isolated