Add configurable sort policy for GetReadyWork (bd-147)

- Add SortPolicy type with hybrid, priority, oldest constants
- Add SortPolicy field to WorkFilter
- Implement buildOrderByClause() for SQL generation
- Add --sort flag to bd ready command
- Add comprehensive tests for all 3 sort policies
- Update RPC protocol to support sort policy
- Update documentation with sort policy examples

Enables autonomous systems like VC to use strict priority ordering
while preserving hybrid behavior for interactive use.

Amp-Thread-ID: https://ampcode.com/threads/T-9d7ea9db-8d6d-4498-9daa-48a7e104ce1f
Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
Steve Yegge
2025-10-25 18:54:00 -07:00
parent b855c444d4
commit 09c11a26e6
8 changed files with 280 additions and 31 deletions

File diff suppressed because one or more lines are too long

View File

@@ -237,6 +237,11 @@ bd ready --limit 20
bd ready --priority 1 bd ready --priority 1
bd ready --assignee alice bd ready --assignee alice
# Sort policies (hybrid is default)
bd ready --sort priority # Strict priority order (P0, P1, P2, P3)
bd ready --sort oldest # Oldest issues first (backlog clearing)
bd ready --sort hybrid # Recent by priority, old by age (default)
# Show blocked issues # Show blocked issues
bd blocked bd blocked

View File

@@ -19,10 +19,12 @@ var readyCmd = &cobra.Command{
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
limit, _ := cmd.Flags().GetInt("limit") limit, _ := cmd.Flags().GetInt("limit")
assignee, _ := cmd.Flags().GetString("assignee") assignee, _ := cmd.Flags().GetString("assignee")
sortPolicy, _ := cmd.Flags().GetString("sort")
filter := types.WorkFilter{ filter := types.WorkFilter{
// Leave Status empty to get both 'open' and 'in_progress' (bd-165) // Leave Status empty to get both 'open' and 'in_progress' (bd-165)
Limit: limit, Limit: limit,
SortPolicy: types.SortPolicy(sortPolicy),
} }
// Use Changed() to properly handle P0 (priority=0) // Use Changed() to properly handle P0 (priority=0)
if cmd.Flags().Changed("priority") { if cmd.Flags().Changed("priority") {
@@ -33,11 +35,18 @@ var readyCmd = &cobra.Command{
filter.Assignee = &assignee filter.Assignee = &assignee
} }
// Validate sort policy
if !filter.SortPolicy.IsValid() {
fmt.Fprintf(os.Stderr, "Error: invalid sort policy '%s'. Valid values: hybrid, priority, oldest\n", sortPolicy)
os.Exit(1)
}
// If daemon is running, use RPC // If daemon is running, use RPC
if daemonClient != nil { if daemonClient != nil {
readyArgs := &rpc.ReadyArgs{ readyArgs := &rpc.ReadyArgs{
Assignee: assignee, Assignee: assignee,
Limit: limit, Limit: limit,
SortPolicy: sortPolicy,
} }
if cmd.Flags().Changed("priority") { if cmd.Flags().Changed("priority") {
priority, _ := cmd.Flags().GetInt("priority") priority, _ := cmd.Flags().GetInt("priority")
@@ -283,6 +292,7 @@ func init() {
readyCmd.Flags().IntP("limit", "n", 10, "Maximum issues to show") readyCmd.Flags().IntP("limit", "n", 10, "Maximum issues to show")
readyCmd.Flags().IntP("priority", "p", 0, "Filter by priority") readyCmd.Flags().IntP("priority", "p", 0, "Filter by priority")
readyCmd.Flags().StringP("assignee", "a", "", "Filter by assignee") readyCmd.Flags().StringP("assignee", "a", "", "Filter by assignee")
readyCmd.Flags().StringP("sort", "s", "hybrid", "Sort policy: hybrid (default), priority, oldest")
rootCmd.AddCommand(readyCmd) rootCmd.AddCommand(readyCmd)
rootCmd.AddCommand(blockedCmd) rootCmd.AddCommand(blockedCmd)

View File

@@ -104,9 +104,10 @@ type ShowArgs struct {
// ReadyArgs represents arguments for the ready operation // ReadyArgs represents arguments for the ready operation
type ReadyArgs struct { type ReadyArgs struct {
Assignee string `json:"assignee,omitempty"` Assignee string `json:"assignee,omitempty"`
Priority *int `json:"priority,omitempty"` Priority *int `json:"priority,omitempty"`
Limit int `json:"limit,omitempty"` Limit int `json:"limit,omitempty"`
SortPolicy string `json:"sort_policy,omitempty"`
} }
// DepAddArgs represents arguments for adding a dependency // DepAddArgs represents arguments for adding a dependency

View File

@@ -1177,9 +1177,10 @@ func (s *Server) handleReady(req *Request) Response {
} }
wf := types.WorkFilter{ wf := types.WorkFilter{
Status: types.StatusOpen, Status: types.StatusOpen,
Priority: readyArgs.Priority, Priority: readyArgs.Priority,
Limit: readyArgs.Limit, Limit: readyArgs.Limit,
SortPolicy: types.SortPolicy(readyArgs.SortPolicy),
} }
if readyArgs.Assignee != "" { if readyArgs.Assignee != "" {
wf.Assignee = &readyArgs.Assignee wf.Assignee = &readyArgs.Assignee

View File

@@ -44,6 +44,13 @@ func (s *SQLiteStorage) GetReadyWork(ctx context.Context, filter types.WorkFilte
args = append(args, filter.Limit) args = append(args, filter.Limit)
} }
// Default to hybrid sort for backwards compatibility
sortPolicy := filter.SortPolicy
if sortPolicy == "" {
sortPolicy = types.SortPolicyHybrid
}
orderBySQL := buildOrderByClause(sortPolicy)
// Query with recursive CTE to propagate blocking through parent-child hierarchy // Query with recursive CTE to propagate blocking through parent-child hierarchy
// Algorithm: // Algorithm:
// 1. Find issues directly blocked by 'blocks' dependencies // 1. Find issues directly blocked by 'blocks' dependencies
@@ -85,23 +92,9 @@ func (s *SQLiteStorage) GetReadyWork(ctx context.Context, filter types.WorkFilte
AND NOT EXISTS ( AND NOT EXISTS (
SELECT 1 FROM blocked_transitively WHERE issue_id = i.id SELECT 1 FROM blocked_transitively WHERE issue_id = i.id
) )
ORDER BY %s
-- Hybrid sort: recent issues (48 hours) by priority, then oldest-first %s
CASE `, whereSQL, orderBySQL, limitSQL)
WHEN datetime(i.created_at) >= datetime('now', '-48 hours') THEN 0
ELSE 1
END ASC,
CASE
WHEN datetime(i.created_at) >= datetime('now', '-48 hours') THEN i.priority
ELSE NULL
END ASC,
CASE
WHEN datetime(i.created_at) < datetime('now', '-48 hours') THEN i.created_at
ELSE NULL
END ASC,
i.created_at ASC
%s
`, whereSQL, limitSQL)
rows, err := s.db.QueryContext(ctx, query, args...) rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil { if err != nil {
@@ -180,3 +173,32 @@ func (s *SQLiteStorage) GetBlockedIssues(ctx context.Context) ([]*types.BlockedI
return blocked, nil return blocked, nil
} }
// buildOrderByClause generates the ORDER BY clause based on sort policy
func buildOrderByClause(policy types.SortPolicy) string {
switch policy {
case types.SortPolicyPriority:
return `ORDER BY i.priority ASC, i.created_at ASC`
case types.SortPolicyOldest:
return `ORDER BY i.created_at ASC`
case types.SortPolicyHybrid:
fallthrough
default:
return `ORDER BY
CASE
WHEN datetime(i.created_at) >= datetime('now', '-48 hours') THEN 0
ELSE 1
END ASC,
CASE
WHEN datetime(i.created_at) >= datetime('now', '-48 hours') THEN i.priority
ELSE NULL
END ASC,
CASE
WHEN datetime(i.created_at) < datetime('now', '-48 hours') THEN i.created_at
ELSE NULL
END ASC,
i.created_at ASC`
}
}

View File

@@ -916,3 +916,181 @@ func TestExplainQueryPlanReadyWork(t *testing.T) {
t.Error("Query plan contains table scans - indexes may not be used efficiently") t.Error("Query plan contains table scans - indexes may not be used efficiently")
} }
} }
// TestSortPolicyPriority tests strict priority-first sorting
func TestSortPolicyPriority(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
// Create issues with mixed ages and priorities
// Old issues (72 hours ago)
issueP0Old := &types.Issue{Title: "old-P0", Status: types.StatusOpen, Priority: 0, IssueType: types.TypeTask}
issueP2Old := &types.Issue{Title: "old-P2", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask}
issueP1Old := &types.Issue{Title: "old-P1", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
// Recent issues (12 hours ago)
issueP3New := &types.Issue{Title: "new-P3", Status: types.StatusOpen, Priority: 3, IssueType: types.TypeTask}
issueP1New := &types.Issue{Title: "new-P1", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
// Create old issues first (to have older created_at)
store.CreateIssue(ctx, issueP0Old, "test-user")
store.CreateIssue(ctx, issueP2Old, "test-user")
store.CreateIssue(ctx, issueP1Old, "test-user")
// Create new issues
store.CreateIssue(ctx, issueP3New, "test-user")
store.CreateIssue(ctx, issueP1New, "test-user")
// Use priority sort policy
ready, err := store.GetReadyWork(ctx, types.WorkFilter{
Status: types.StatusOpen,
SortPolicy: types.SortPolicyPriority,
})
if err != nil {
t.Fatalf("GetReadyWork failed: %v", err)
}
if len(ready) != 5 {
t.Fatalf("Expected 5 ready issues, got %d", len(ready))
}
// Verify strict priority ordering: P0, P1, P1, P2, P3
// Within same priority, older created_at comes first
expectedOrder := []struct {
title string
priority int
}{
{"old-P0", 0},
{"old-P1", 1},
{"new-P1", 1},
{"old-P2", 2},
{"new-P3", 3},
}
for i, expected := range expectedOrder {
if ready[i].Title != expected.title {
t.Errorf("Position %d: expected %s, got %s", i, expected.title, ready[i].Title)
}
if ready[i].Priority != expected.priority {
t.Errorf("Position %d: expected P%d, got P%d", i, expected.priority, ready[i].Priority)
}
}
}
// TestSortPolicyOldest tests oldest-first sorting (ignoring priority)
func TestSortPolicyOldest(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
// Create issues in order: P2, P0, P1 (mixed priority, chronological creation)
issueP2 := &types.Issue{Title: "first-P2", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask}
issueP0 := &types.Issue{Title: "second-P0", Status: types.StatusOpen, Priority: 0, IssueType: types.TypeTask}
issueP1 := &types.Issue{Title: "third-P1", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
store.CreateIssue(ctx, issueP2, "test-user")
store.CreateIssue(ctx, issueP0, "test-user")
store.CreateIssue(ctx, issueP1, "test-user")
// Use oldest sort policy
ready, err := store.GetReadyWork(ctx, types.WorkFilter{
Status: types.StatusOpen,
SortPolicy: types.SortPolicyOldest,
})
if err != nil {
t.Fatalf("GetReadyWork failed: %v", err)
}
if len(ready) != 3 {
t.Fatalf("Expected 3 ready issues, got %d", len(ready))
}
// Should be sorted by creation time only (oldest first)
expectedTitles := []string{"first-P2", "second-P0", "third-P1"}
for i, expected := range expectedTitles {
if ready[i].Title != expected {
t.Errorf("Position %d: expected %s, got %s", i, expected, ready[i].Title)
}
}
}
// TestSortPolicyHybrid tests hybrid sort (default behavior)
func TestSortPolicyHybrid(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
// Create issues with different priorities
// All created recently (within 48 hours in test), so should sort by priority
issueP0 := &types.Issue{Title: "issue-P0", Status: types.StatusOpen, Priority: 0, IssueType: types.TypeTask}
issueP2 := &types.Issue{Title: "issue-P2", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask}
issueP1 := &types.Issue{Title: "issue-P1", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
issueP3 := &types.Issue{Title: "issue-P3", Status: types.StatusOpen, Priority: 3, IssueType: types.TypeTask}
store.CreateIssue(ctx, issueP2, "test-user")
store.CreateIssue(ctx, issueP0, "test-user")
store.CreateIssue(ctx, issueP3, "test-user")
store.CreateIssue(ctx, issueP1, "test-user")
// Use hybrid sort policy (explicit)
ready, err := store.GetReadyWork(ctx, types.WorkFilter{
Status: types.StatusOpen,
SortPolicy: types.SortPolicyHybrid,
})
if err != nil {
t.Fatalf("GetReadyWork failed: %v", err)
}
if len(ready) != 4 {
t.Fatalf("Expected 4 ready issues, got %d", len(ready))
}
// Since all issues are created recently (< 48 hours in test context),
// hybrid sort should order by priority: P0, P1, P2, P3
expectedPriorities := []int{0, 1, 2, 3}
for i, expected := range expectedPriorities {
if ready[i].Priority != expected {
t.Errorf("Position %d: expected P%d, got P%d", i, expected, ready[i].Priority)
}
}
}
// TestSortPolicyDefault tests that empty sort policy defaults to hybrid
func TestSortPolicyDefault(t *testing.T) {
store, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
// Create test issues with different priorities
issueP1 := &types.Issue{Title: "issue-P1", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
issueP2 := &types.Issue{Title: "issue-P2", Status: types.StatusOpen, Priority: 2, IssueType: types.TypeTask}
store.CreateIssue(ctx, issueP2, "test-user")
store.CreateIssue(ctx, issueP1, "test-user")
// Use default (empty) sort policy
ready, err := store.GetReadyWork(ctx, types.WorkFilter{
Status: types.StatusOpen,
// SortPolicy not specified - should default to hybrid
})
if err != nil {
t.Fatalf("GetReadyWork failed: %v", err)
}
if len(ready) != 2 {
t.Fatalf("Expected 2 ready issues, got %d", len(ready))
}
// Should behave like hybrid: since both are recent, sort by priority (P1 first)
if ready[0].Priority != 1 {
t.Errorf("Expected P1 first (hybrid default, recent by priority), got P%d", ready[0].Priority)
}
if ready[1].Priority != 2 {
t.Errorf("Expected P2 second, got P%d", ready[1].Priority)
}
}

View File

@@ -216,12 +216,41 @@ type IssueFilter struct {
Limit int Limit int
} }
// SortPolicy determines how ready work is ordered
type SortPolicy string
// Sort policy constants
const (
// SortPolicyHybrid prioritizes recent issues by priority, older by age
// Recent = created within 48 hours
// This is the default for backwards compatibility
SortPolicyHybrid SortPolicy = "hybrid"
// SortPolicyPriority always sorts by priority first, then creation date
// Use for autonomous execution, CI/CD, priority-driven workflows
SortPolicyPriority SortPolicy = "priority"
// SortPolicyOldest always sorts by creation date (oldest first)
// Use for backlog clearing, preventing issue starvation
SortPolicyOldest SortPolicy = "oldest"
)
// IsValid checks if the sort policy value is valid
func (s SortPolicy) IsValid() bool {
switch s {
case SortPolicyHybrid, SortPolicyPriority, SortPolicyOldest, "":
return true
}
return false
}
// WorkFilter is used to filter ready work queries // WorkFilter is used to filter ready work queries
type WorkFilter struct { type WorkFilter struct {
Status Status Status Status
Priority *int Priority *int
Assignee *string Assignee *string
Limit int Limit int
SortPolicy SortPolicy
} }
// EpicStatus represents an epic with its completion status // EpicStatus represents an epic with its completion status