feat(convoy): Show active workers in convoy status output (gt-w5xj2)
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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-<rig>-<role>-<name> or gt-<rig>-<name>
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user