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 --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
bd blocked

View File

@@ -19,10 +19,12 @@ var readyCmd = &cobra.Command{
Run: func(cmd *cobra.Command, args []string) {
limit, _ := cmd.Flags().GetInt("limit")
assignee, _ := cmd.Flags().GetString("assignee")
sortPolicy, _ := cmd.Flags().GetString("sort")
filter := types.WorkFilter{
// 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)
if cmd.Flags().Changed("priority") {
@@ -33,11 +35,18 @@ var readyCmd = &cobra.Command{
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 daemonClient != nil {
readyArgs := &rpc.ReadyArgs{
Assignee: assignee,
Limit: limit,
Assignee: assignee,
Limit: limit,
SortPolicy: sortPolicy,
}
if cmd.Flags().Changed("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("priority", "p", 0, "Filter by priority")
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(blockedCmd)

View File

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

View File

@@ -1177,9 +1177,10 @@ func (s *Server) handleReady(req *Request) Response {
}
wf := types.WorkFilter{
Status: types.StatusOpen,
Priority: readyArgs.Priority,
Limit: readyArgs.Limit,
Status: types.StatusOpen,
Priority: readyArgs.Priority,
Limit: readyArgs.Limit,
SortPolicy: types.SortPolicy(readyArgs.SortPolicy),
}
if 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)
}
// 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
// Algorithm:
// 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 (
SELECT 1 FROM blocked_transitively WHERE issue_id = i.id
)
ORDER BY
-- Hybrid sort: recent issues (48 hours) by priority, then oldest-first
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
%s
`, whereSQL, limitSQL)
%s
%s
`, whereSQL, orderBySQL, limitSQL)
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
@@ -180,3 +173,32 @@ func (s *SQLiteStorage) GetBlockedIssues(ctx context.Context) ([]*types.BlockedI
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")
}
}
// 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
}
// 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
type WorkFilter struct {
Status Status
Priority *int
Assignee *string
Limit int
Status Status
Priority *int
Assignee *string
Limit int
SortPolicy SortPolicy
}
// EpicStatus represents an epic with its completion status