@@ -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