Skip to content

Commit cea3e8a

Browse files
outdoorseaclaude
andcommitted
feat(convoy): dashboard enrichment, auto-GC, and strandedConvoyInfo fix
- Enrich convoy panel with progress %, ready/active counts, assignees - Auto-GC idle-assignee leg beads - Add omitempty to strandedConvoyInfo.CreatedAt Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 4894b0d commit cea3e8a

4 files changed

Lines changed: 457 additions & 0 deletions

File tree

internal/cmd/convoy.go

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,34 @@ Examples:
372372
RunE: runConvoyLand,
373373
}
374374

375+
var (
376+
convoyGCJSON bool
377+
)
378+
379+
var convoyGCCmd = &cobra.Command{
380+
Use: "gc <convoy-id>",
381+
Short: "GC convoy legs with idle/done assignee polecats",
382+
Long: `Garbage-collect convoy legs whose assignee polecat is idle, stuck, or nuked.
383+
384+
When a polecat goes idle (via gt done) or gets stuck without properly closing
385+
its convoy leg bead, the convoy gets stuck: tracked issues exist but none are
386+
ready. This command finds and closes those legs so the convoy can progress.
387+
388+
For each open tracked bead in the convoy:
389+
1. Look up the assignee (e.g., gastown/polecats/dag)
390+
2. Resolve to agent bead ID (e.g., gt-gastown-polecat-dag)
391+
3. If agent_state is idle, stuck, or nuked → close the leg
392+
393+
Returns count of legs closed (0 if all assignees are still active).
394+
395+
Examples:
396+
gt convoy gc hq-cv-abc # GC idle legs in a convoy
397+
gt convoy gc hq-cv-abc --json # Machine-readable output`,
398+
Args: cobra.ExactArgs(1),
399+
SilenceUsage: true,
400+
RunE: runConvoyGC,
401+
}
402+
375403
func init() {
376404
// Create flags
377405
convoyCreateCmd.Flags().StringVar(&convoyMolecule, "molecule", "", "Associated molecule ID")
@@ -411,6 +439,9 @@ func init() {
411439
convoyLandCmd.Flags().BoolVar(&convoyLandKeep, "keep-worktrees", false, "Skip worktree cleanup")
412440
convoyLandCmd.Flags().BoolVar(&convoyLandDryRun, "dry-run", false, "Show what would happen without acting")
413441

442+
// GC flags
443+
convoyGCCmd.Flags().BoolVar(&convoyGCJSON, "json", false, "Output as JSON")
444+
414445
// Add subcommands
415446
convoyCmd.AddCommand(convoyCreateCmd)
416447
convoyCmd.AddCommand(convoyStatusCmd)
@@ -422,6 +453,7 @@ func init() {
422453
convoyCmd.AddCommand(convoyLandCmd)
423454
convoyCmd.AddCommand(convoyStageCmd)
424455
convoyCmd.AddCommand(convoyLaunchCmd)
456+
convoyCmd.AddCommand(convoyGCCmd)
425457

426458
rootCmd.AddCommand(convoyCmd)
427459
}
@@ -1006,6 +1038,38 @@ func checkSingleConvoy(townBeads, convoyID string, dryRun bool) error {
10061038
return err
10071039
}
10081040

1041+
func runConvoyGC(cmd *cobra.Command, args []string) error {
1042+
convoyID := args[0]
1043+
1044+
townRoot, err := workspace.FindFromCwdOrError()
1045+
if err != nil {
1046+
return fmt.Errorf("not in a Gas Town workspace: %w", err)
1047+
}
1048+
1049+
ctx := cmd.Context()
1050+
logger := func(format string, args ...interface{}) {
1051+
if !convoyGCJSON {
1052+
fmt.Fprintf(os.Stderr, format+"\n", args...)
1053+
}
1054+
}
1055+
1056+
result := convoyops.GCIdleAssigneeLegs(ctx, townRoot, convoyID, "gc", logger)
1057+
1058+
if convoyGCJSON {
1059+
enc := json.NewEncoder(os.Stdout)
1060+
enc.SetIndent("", " ")
1061+
return enc.Encode(result)
1062+
}
1063+
1064+
if result.LegsClosed == 0 {
1065+
fmt.Printf("%s Convoy %s: no idle-assignee legs to GC\n", style.Dim.Render("○"), convoyID)
1066+
} else {
1067+
fmt.Printf("%s Convoy %s: closed %d idle-assignee leg(s)\n", style.Bold.Render("✓"), convoyID, result.LegsClosed)
1068+
}
1069+
1070+
return nil
1071+
}
1072+
10091073
func runConvoyClose(cmd *cobra.Command, args []string) error {
10101074
convoyID := args[0]
10111075

internal/convoy/operations.go

Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -522,6 +522,249 @@ func fetchCrossRigBeadStatus(townRoot string, ids []string) map[string]*beadsdk.
522522
return result
523523
}
524524

525+
// GCIdleAssigneeResult holds the result of a GC operation.
526+
type GCIdleAssigneeResult struct {
527+
ConvoyID string `json:"convoy_id"`
528+
LegsClosed int `json:"legs_closed"`
529+
ClosedIDs []string `json:"closed_ids,omitempty"`
530+
}
531+
532+
// GCIdleAssigneeLegs finds open legs in a convoy whose assignee is a polecat
533+
// with agent_state idle, stuck, or nuked, and closes them. Returns the result
534+
// including count and IDs of legs closed. This unblocks convoys stuck because
535+
// polecats went idle/done without properly closing their leg beads.
536+
//
537+
// Uses bd CLI commands (no store dependency) so it can be called from both
538+
// the CLI command and the daemon (via subprocess).
539+
func GCIdleAssigneeLegs(ctx context.Context, townRoot, convoyID, caller string, logger func(format string, args ...interface{})) GCIdleAssigneeResult {
540+
if logger == nil {
541+
logger = func(format string, args ...interface{}) {}
542+
}
543+
544+
result := GCIdleAssigneeResult{ConvoyID: convoyID}
545+
546+
// Get tracked issues via bd dep list
547+
tracked := getTrackedIssuesViaCLI(townRoot, convoyID)
548+
if len(tracked) == 0 {
549+
return result
550+
}
551+
552+
for _, issue := range tracked {
553+
// Only look at non-closed issues with an assignee
554+
if issue.Status == "closed" || issue.Status == "tombstone" || issue.Assignee == "" {
555+
continue
556+
}
557+
558+
// Check if the assignee is a polecat with idle/stuck/nuked state
559+
agentState := queryAssigneeAgentState(townRoot, issue.Assignee)
560+
if agentState == "" {
561+
continue // Can't determine state, skip
562+
}
563+
564+
// GC if the polecat is idle, stuck, or nuked (all indicate it's not working on this leg)
565+
switch agentState {
566+
case "idle", "stuck", "nuked":
567+
reason := fmt.Sprintf("auto-gc: assignee polecat %s (agent_state=%s)", issue.Assignee, agentState)
568+
logger("%s: convoy %s: closing leg %s — %s", caller, convoyID, issue.ID, reason)
569+
570+
if err := closeTrackedLeg(ctx, townRoot, issue.ID, reason); err != nil {
571+
logger("%s: convoy %s: failed to close leg %s: %s", caller, convoyID, issue.ID, err)
572+
continue
573+
}
574+
result.LegsClosed++
575+
result.ClosedIDs = append(result.ClosedIDs, issue.ID)
576+
}
577+
}
578+
579+
return result
580+
}
581+
582+
// cliTrackedIssue holds basic info from bd dep list for GC purposes.
583+
type cliTrackedIssue struct {
584+
ID string `json:"id"`
585+
Status string `json:"status"`
586+
Assignee string `json:"assignee"`
587+
}
588+
589+
// getTrackedIssuesViaCLI fetches tracked issues for a convoy using bd dep list.
590+
func getTrackedIssuesViaCLI(townRoot, convoyID string) []cliTrackedIssue {
591+
cmd := exec.Command("bd", "dep", "list", convoyID, "--direction=down", "--type=tracks", "--json")
592+
cmd.Dir = townRoot
593+
var stdout bytes.Buffer
594+
cmd.Stdout = &stdout
595+
596+
if err := cmd.Run(); err != nil {
597+
return nil
598+
}
599+
600+
var deps []struct {
601+
ID string `json:"id"`
602+
Status string `json:"status"`
603+
Assignee string `json:"assignee"`
604+
}
605+
if err := json.Unmarshal(stdout.Bytes(), &deps); err != nil {
606+
return nil
607+
}
608+
609+
// Unwrap external:prefix:id and refresh status via bd show
610+
var ids []string
611+
for i := range deps {
612+
deps[i].ID = extractIssueID(deps[i].ID)
613+
ids = append(ids, deps[i].ID)
614+
}
615+
616+
// Batch-refresh status and assignee via bd show --json
617+
if len(ids) > 0 {
618+
freshMap := batchShowIssues(townRoot, ids)
619+
for i := range deps {
620+
if fresh, ok := freshMap[deps[i].ID]; ok {
621+
deps[i].Status = fresh.Status
622+
if fresh.Assignee != "" {
623+
deps[i].Assignee = fresh.Assignee
624+
}
625+
}
626+
}
627+
}
628+
629+
result := make([]cliTrackedIssue, len(deps))
630+
for i, d := range deps {
631+
result[i] = cliTrackedIssue{ID: d.ID, Status: d.Status, Assignee: d.Assignee}
632+
}
633+
return result
634+
}
635+
636+
// batchShowIssues fetches fresh issue details for multiple IDs via bd show --json.
637+
func batchShowIssues(townRoot string, ids []string) map[string]struct{ Status, Assignee string } {
638+
result := make(map[string]struct{ Status, Assignee string })
639+
if len(ids) == 0 {
640+
return result
641+
}
642+
643+
args := append([]string{"show", "--json"}, ids...)
644+
cmd := exec.Command("bd", args...)
645+
cmd.Dir = townRoot
646+
var stdout bytes.Buffer
647+
cmd.Stdout = &stdout
648+
649+
if err := cmd.Run(); err != nil {
650+
return result
651+
}
652+
653+
var issues []struct {
654+
ID string `json:"id"`
655+
Status string `json:"status"`
656+
Assignee string `json:"assignee"`
657+
}
658+
if err := json.Unmarshal(stdout.Bytes(), &issues); err != nil {
659+
return result
660+
}
661+
662+
for _, iss := range issues {
663+
result[iss.ID] = struct{ Status, Assignee string }{iss.Status, iss.Assignee}
664+
}
665+
return result
666+
}
667+
668+
// resolvePolecatBeadID parses a mail-style assignee address and resolves it
669+
// to the agent bead ID for a polecat. Returns ("", "", false) if the assignee
670+
// is not a polecat address.
671+
func resolvePolecatBeadID(townRoot, assignee string) (agentBeadID string, polecatName string, ok bool) {
672+
parts := strings.Split(strings.TrimSuffix(assignee, "/"), "/")
673+
if len(parts) < 2 {
674+
return "", "", false
675+
}
676+
677+
var rig, name string
678+
switch len(parts) {
679+
case 2:
680+
// Short form: rig/name — could be polecat or other role
681+
rig = parts[0]
682+
role := parts[1]
683+
// Skip known singleton roles
684+
if role == "witness" || role == "refinery" {
685+
return "", "", false
686+
}
687+
name = role
688+
case 3:
689+
// Explicit: rig/polecats/name or rig/crew/name
690+
rig = parts[0]
691+
if parts[1] != "polecats" {
692+
return "", "", false // Not a polecat
693+
}
694+
name = parts[2]
695+
default:
696+
return "", "", false
697+
}
698+
699+
// Resolve rig prefix using beads routing
700+
prefix := beads.GetPrefixForRig(townRoot, rig)
701+
if prefix == "" {
702+
prefix = "gt"
703+
}
704+
705+
beadID := beads.AgentBeadIDWithPrefix(prefix, rig, "polecat", name)
706+
return beadID, name, true
707+
}
708+
709+
// queryAssigneeAgentState resolves a mail-style assignee address to an agent bead ID
710+
// and queries the agent bead's agent_state. Returns empty string if the assignee
711+
// is not a polecat or the state can't be determined.
712+
func queryAssigneeAgentState(townRoot, assignee string) string {
713+
agentBeadID, _, ok := resolvePolecatBeadID(townRoot, assignee)
714+
if !ok {
715+
return ""
716+
}
717+
718+
// Query agent bead via bd show --json
719+
cmd := exec.Command("bd", "show", agentBeadID, "--json")
720+
cmd.Dir = townRoot
721+
var stdout bytes.Buffer
722+
cmd.Stdout = &stdout
723+
724+
if err := cmd.Run(); err != nil {
725+
return "" // Can't query, skip
726+
}
727+
728+
return parseAgentStateFromShowJSON(stdout.Bytes())
729+
}
730+
731+
// parseAgentStateFromShowJSON extracts agent_state from bd show --json output.
732+
func parseAgentStateFromShowJSON(data []byte) string {
733+
var issues []struct {
734+
Description string `json:"description"`
735+
}
736+
if err := json.Unmarshal(data, &issues); err != nil || len(issues) == 0 {
737+
return ""
738+
}
739+
740+
for _, line := range strings.Split(issues[0].Description, "\n") {
741+
line = strings.TrimSpace(line)
742+
if strings.HasPrefix(line, "agent_state:") {
743+
state := strings.TrimSpace(strings.TrimPrefix(line, "agent_state:"))
744+
if state == "null" || state == "" {
745+
return ""
746+
}
747+
return state
748+
}
749+
}
750+
751+
return ""
752+
}
753+
754+
// closeTrackedLeg closes a convoy leg bead with a reason via bd close.
755+
func closeTrackedLeg(ctx context.Context, townRoot, issueID, reason string) error {
756+
cmd := exec.CommandContext(ctx, "bd", "close", issueID, "--reason="+reason, "--force")
757+
cmd.Dir = townRoot
758+
util.SetProcessGroup(cmd)
759+
var stderr bytes.Buffer
760+
cmd.Stderr = &stderr
761+
762+
if err := cmd.Run(); err != nil {
763+
return fmt.Errorf("%v: %s", err, strings.TrimSpace(stderr.String()))
764+
}
765+
return nil
766+
}
767+
525768
// dispatchIssue dispatches an issue to a rig via gt sling.
526769
// The context parameter enables cancellation on daemon shutdown.
527770
// gtPath is the resolved path to the gt binary.

0 commit comments

Comments
 (0)