From 937ee2c8b661a3452f9ba82690f4e61380194f73 Mon Sep 17 00:00:00 2001 From: rictus Date: Thu, 1 Jan 2026 19:29:20 -0800 Subject: [PATCH] feat(convoy): Show active workers in convoy status output (gt-w5xj2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add worker information to convoy status display: - Query agent beads across rigs for matching hook_bead - Display worker inline with tracked issues: @gastown/nux (12m) - Include Worker and WorkerAge in JSON output - Skip worker lookup for closed issues Example output: Tracked Issues: ○ gt-xyz: Fix bug [task] @gastown/nux (12m) ✓ gt-abc: Add feature [task] 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/cmd/convoy.go | 148 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 147 insertions(+), 1 deletion(-) diff --git a/internal/cmd/convoy.go b/internal/cmd/convoy.go index f42381da..3bcb0794 100644 --- a/internal/cmd/convoy.go +++ b/internal/cmd/convoy.go @@ -11,6 +11,7 @@ import ( "path/filepath" "strconv" "strings" + "time" tea "github.com/charmbracelet/bubbletea" "github.com/spf13/cobra" @@ -438,7 +439,15 @@ func runConvoyStatus(cmd *cobra.Command, args []string) error { if issueType == "" { issueType = "task" } - fmt.Printf(" %s %s: %s [%s]\n", status, t.ID, t.Title, issueType) + line := fmt.Sprintf(" %s %s: %s [%s]", status, t.ID, t.Title, issueType) + if t.Worker != "" { + workerDisplay := "@" + t.Worker + if t.WorkerAge != "" { + workerDisplay += fmt.Sprintf(" (%s)", t.WorkerAge) + } + line += fmt.Sprintf(" %s", style.Dim.Render(workerDisplay)) + } + fmt.Println(line) } } @@ -563,6 +572,8 @@ type trackedIssueInfo struct { Status string `json:"status"` Type string `json:"dependency_type"` IssueType string `json:"issue_type"` + Worker string `json:"worker,omitempty"` // Worker currently assigned (e.g., gastown/nux) + WorkerAge string `json:"worker_age,omitempty"` // How long worker has been on this issue } // getTrackedIssues queries SQLite directly to get issues tracked by a convoy. @@ -612,6 +623,15 @@ func getTrackedIssues(townBeads, convoyID string) []trackedIssueInfo { // Single batch call to get all issue details detailsMap := getIssueDetailsBatch(issueIDs) + // Get workers for these issues (only for non-closed issues) + openIssueIDs := make([]string, 0, len(issueIDs)) + for _, id := range issueIDs { + if details, ok := detailsMap[id]; ok && details.Status != "closed" { + openIssueIDs = append(openIssueIDs, id) + } + } + workersMap := getWorkersForIssues(openIssueIDs) + // Second pass: build result using the batch lookup var tracked []trackedIssueInfo for _, issueID := range issueIDs { @@ -629,6 +649,12 @@ func getTrackedIssues(townBeads, convoyID string) []trackedIssueInfo { info.Status = "unknown" } + // Add worker info if available + if worker, ok := workersMap[issueID]; ok { + info.Worker = worker.Worker + info.WorkerAge = worker.Age + } + tracked = append(tracked, info) } @@ -722,6 +748,126 @@ func getIssueDetails(issueID string) *issueDetails { } } +// workerInfo holds info about a worker assigned to an issue. +type workerInfo struct { + Worker string // Agent identity (e.g., gastown/nux) + Age string // How long assigned (e.g., "12m") +} + +// getWorkersForIssues finds workers currently assigned to the given issues. +// Returns a map from issue ID to worker info. +func getWorkersForIssues(issueIDs []string) map[string]*workerInfo { + result := make(map[string]*workerInfo) + if len(issueIDs) == 0 { + return result + } + + // Query agent beads where hook_bead matches one of our issues + // We need to check beads across all rigs, so query each potential rig + + // Find town root + townRoot, err := workspace.FindFromCwd() + if err != nil || townRoot == "" { + return result + } + + // Discover rigs + rigDirs, _ := filepath.Glob(filepath.Join(townRoot, "*", "polecats")) + for _, polecatsDir := range rigDirs { + rigDir := filepath.Dir(polecatsDir) + beadsDB := filepath.Join(rigDir, "mayor", "rig", ".beads", "beads.db") + + // Check if beads.db exists + if _, err := os.Stat(beadsDB); err != nil { + continue + } + + // Query for agent beads with matching hook_bead + for _, issueID := range issueIDs { + if _, ok := result[issueID]; ok { + continue // Already found a worker for this issue + } + + // Query for agent bead with this hook_bead + safeID := strings.ReplaceAll(issueID, "'", "''") + query := fmt.Sprintf( + `SELECT id, hook_bead, last_activity FROM issues WHERE issue_type = 'agent' AND status = 'open' AND hook_bead = '%s' LIMIT 1`, + safeID) + + queryCmd := exec.Command("sqlite3", "-json", beadsDB, query) + var stdout bytes.Buffer + queryCmd.Stdout = &stdout + if err := queryCmd.Run(); err != nil { + continue + } + + var agents []struct { + ID string `json:"id"` + HookBead string `json:"hook_bead"` + LastActivity string `json:"last_activity"` + } + if err := json.Unmarshal(stdout.Bytes(), &agents); err != nil || len(agents) == 0 { + continue + } + + agent := agents[0] + + // Parse agent ID to get worker identity + // Format: gt--- or gt-- + workerID := parseWorkerFromAgentBead(agent.ID) + if workerID == "" { + continue + } + + // Calculate age from last_activity + age := "" + if agent.LastActivity != "" { + if t, err := time.Parse(time.RFC3339, agent.LastActivity); err == nil { + age = formatWorkerAge(time.Since(t)) + } + } + + result[issueID] = &workerInfo{ + Worker: workerID, + Age: age, + } + } + } + + return result +} + +// parseWorkerFromAgentBead extracts worker identity from agent bead ID. +// Input: "gt-gastown-polecat-nux" -> Output: "gastown/nux" +// Input: "gt-beads-crew-amber" -> Output: "beads/crew/amber" +func parseWorkerFromAgentBead(agentID string) string { + // Remove prefix (gt-, bd-, etc.) + parts := strings.Split(agentID, "-") + if len(parts) < 3 { + return "" + } + + // Skip prefix + parts = parts[1:] + + // Reconstruct as path + return strings.Join(parts, "/") +} + +// formatWorkerAge formats a duration as a short string (e.g., "5m", "2h", "1d") +func formatWorkerAge(d time.Duration) string { + if d < time.Minute { + return "<1m" + } + if d < time.Hour { + return fmt.Sprintf("%dm", int(d.Minutes())) + } + if d < 24*time.Hour { + return fmt.Sprintf("%dh", int(d.Hours())) + } + return fmt.Sprintf("%dd", int(d.Hours()/24)) +} + // runConvoyTUI launches the interactive convoy TUI. func runConvoyTUI() error { townBeads, err := getTownBeadsDir()