feat: add parallel step detection for molecules (bd-xo1o.4)
Add --parallel flag to `bd mol show` that analyzes molecule structure and identifies which steps can run in parallel. Also add --mol flag to `bd ready` to list ready steps within a specific molecule. Features: - `bd mol show <id> --parallel`: Shows parallel group annotations - `bd ready --mol <id>`: Lists ready steps with parallel opportunities - Detects steps at same blocking depth that can parallelize - Groups steps without mutual blocking deps into parallel groups - JSON output includes full parallel analysis Algorithm: - Build dependency graph from blocking deps (blocks, conditional-blocks) - Calculate blocking depth (distance from unblocked state) - Group steps at same depth with no mutual blocking into parallel groups - Mark steps as ready if open/in_progress with no open blockers 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,16 +3,32 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/beads/internal/types"
|
||||
"github.com/steveyegge/beads/internal/ui"
|
||||
"github.com/steveyegge/beads/internal/utils"
|
||||
)
|
||||
|
||||
var (
|
||||
molShowParallel bool // --parallel flag for parallel detection
|
||||
)
|
||||
|
||||
var molShowCmd = &cobra.Command{
|
||||
Use: "show <molecule-id>",
|
||||
Short: "Show molecule details",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Long: `Show molecule structure and details.
|
||||
|
||||
The --parallel flag highlights parallelizable steps:
|
||||
- Steps with no blocking dependencies can run in parallel
|
||||
- Shows which steps are ready to start now
|
||||
- Identifies parallel groups (steps that can run concurrently)
|
||||
|
||||
Example:
|
||||
bd mol show bd-patrol --parallel`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
ctx := rootCtx
|
||||
|
||||
@@ -39,7 +55,11 @@ var molShowCmd = &cobra.Command{
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
showMolecule(subgraph)
|
||||
if molShowParallel {
|
||||
showMoleculeWithParallel(subgraph)
|
||||
} else {
|
||||
showMolecule(subgraph)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -71,6 +91,346 @@ func showMolecule(subgraph *MoleculeSubgraph) {
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
// ParallelInfo holds parallel analysis information for a step
|
||||
type ParallelInfo struct {
|
||||
StepID string `json:"step_id"`
|
||||
Status string `json:"status"`
|
||||
IsReady bool `json:"is_ready"` // Can start now (no blocking deps)
|
||||
ParallelGroup string `json:"parallel_group"` // Group ID (steps with same group can parallelize)
|
||||
BlockedBy []string `json:"blocked_by"` // IDs of open steps blocking this one
|
||||
Blocks []string `json:"blocks"` // IDs of steps this one blocks
|
||||
CanParallel []string `json:"can_parallel"` // IDs of steps that can run in parallel with this
|
||||
}
|
||||
|
||||
// ParallelAnalysis holds the complete parallel analysis for a molecule
|
||||
type ParallelAnalysis struct {
|
||||
MoleculeID string `json:"molecule_id"`
|
||||
TotalSteps int `json:"total_steps"`
|
||||
ReadySteps int `json:"ready_steps"`
|
||||
ParallelGroups map[string][]string `json:"parallel_groups"` // group ID -> step IDs
|
||||
Steps map[string]*ParallelInfo `json:"steps"`
|
||||
}
|
||||
|
||||
// analyzeMoleculeParallel performs parallel detection on a molecule subgraph.
|
||||
// Returns analysis of which steps can run in parallel.
|
||||
func analyzeMoleculeParallel(subgraph *MoleculeSubgraph) *ParallelAnalysis {
|
||||
analysis := &ParallelAnalysis{
|
||||
MoleculeID: subgraph.Root.ID,
|
||||
TotalSteps: len(subgraph.Issues),
|
||||
ParallelGroups: make(map[string][]string),
|
||||
Steps: make(map[string]*ParallelInfo),
|
||||
}
|
||||
|
||||
// Build dependency maps
|
||||
// blockedBy[id] = set of issue IDs that block this issue
|
||||
// blocks[id] = set of issue IDs that this issue blocks
|
||||
blockedBy := make(map[string]map[string]bool)
|
||||
blocks := make(map[string]map[string]bool)
|
||||
|
||||
for _, issue := range subgraph.Issues {
|
||||
blockedBy[issue.ID] = make(map[string]bool)
|
||||
blocks[issue.ID] = make(map[string]bool)
|
||||
}
|
||||
|
||||
// Process dependencies to find blocking relationships
|
||||
for _, dep := range subgraph.Dependencies {
|
||||
// Only blocking dependencies affect parallel execution
|
||||
if dep.Type == types.DepBlocks || dep.Type == types.DepConditionalBlocks {
|
||||
// dep.IssueID depends on (is blocked by) dep.DependsOnID
|
||||
if _, ok := blockedBy[dep.IssueID]; ok {
|
||||
blockedBy[dep.IssueID][dep.DependsOnID] = true
|
||||
}
|
||||
if _, ok := blocks[dep.DependsOnID]; ok {
|
||||
blocks[dep.DependsOnID][dep.IssueID] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Identify which steps are ready (no open blockers)
|
||||
readySteps := make(map[string]bool)
|
||||
for _, issue := range subgraph.Issues {
|
||||
info := &ParallelInfo{
|
||||
StepID: issue.ID,
|
||||
Status: string(issue.Status),
|
||||
BlockedBy: []string{},
|
||||
Blocks: []string{},
|
||||
}
|
||||
|
||||
// Check what blocks this step
|
||||
for blockerID := range blockedBy[issue.ID] {
|
||||
blocker := subgraph.IssueMap[blockerID]
|
||||
if blocker != nil && blocker.Status != types.StatusClosed {
|
||||
info.BlockedBy = append(info.BlockedBy, blockerID)
|
||||
}
|
||||
}
|
||||
|
||||
// Check what this step blocks
|
||||
for blockedID := range blocks[issue.ID] {
|
||||
info.Blocks = append(info.Blocks, blockedID)
|
||||
}
|
||||
|
||||
// A step is ready if it's open/in_progress and has no open blockers
|
||||
info.IsReady = (issue.Status == types.StatusOpen || issue.Status == types.StatusInProgress) &&
|
||||
len(info.BlockedBy) == 0
|
||||
|
||||
if info.IsReady {
|
||||
readySteps[issue.ID] = true
|
||||
analysis.ReadySteps++
|
||||
}
|
||||
|
||||
// Sort for consistent output
|
||||
sort.Strings(info.BlockedBy)
|
||||
sort.Strings(info.Blocks)
|
||||
|
||||
analysis.Steps[issue.ID] = info
|
||||
}
|
||||
|
||||
// Identify parallel groups: steps that can run concurrently
|
||||
// Two steps can parallelize if:
|
||||
// 1. Both are ready (or will be ready at same time)
|
||||
// 2. Neither blocks the other (directly or transitively)
|
||||
// 3. They share the same blocking depth (distance from root)
|
||||
|
||||
// Calculate blocking depth for each step
|
||||
depths := calculateBlockingDepths(subgraph, blockedBy)
|
||||
|
||||
// Group steps by depth - steps at same depth can potentially parallelize
|
||||
depthGroups := make(map[int][]string)
|
||||
for id, depth := range depths {
|
||||
depthGroups[depth] = append(depthGroups[depth], id)
|
||||
}
|
||||
|
||||
// For each depth level, identify parallel groups
|
||||
groupCounter := 0
|
||||
for depth := 0; depth <= len(subgraph.Issues); depth++ {
|
||||
stepsAtDepth := depthGroups[depth]
|
||||
if len(stepsAtDepth) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Group steps that can parallelize (no blocking between them)
|
||||
// Use union-find approach: start with each step in its own group
|
||||
parent := make(map[string]string)
|
||||
for _, id := range stepsAtDepth {
|
||||
parent[id] = id
|
||||
}
|
||||
|
||||
find := func(x string) string {
|
||||
for parent[x] != x {
|
||||
parent[x] = parent[parent[x]]
|
||||
x = parent[x]
|
||||
}
|
||||
return x
|
||||
}
|
||||
|
||||
union := func(x, y string) {
|
||||
px, py := find(x), find(y)
|
||||
if px != py {
|
||||
parent[px] = py
|
||||
}
|
||||
}
|
||||
|
||||
// Merge steps that CAN parallelize (no mutual blocking)
|
||||
for i, id1 := range stepsAtDepth {
|
||||
for j := i + 1; j < len(stepsAtDepth); j++ {
|
||||
id2 := stepsAtDepth[j]
|
||||
// Can parallelize if neither blocks the other
|
||||
if !blocks[id1][id2] && !blocks[id2][id1] &&
|
||||
!blockedBy[id1][id2] && !blockedBy[id2][id1] {
|
||||
union(id1, id2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Collect groups
|
||||
groups := make(map[string][]string)
|
||||
for _, id := range stepsAtDepth {
|
||||
root := find(id)
|
||||
groups[root] = append(groups[root], id)
|
||||
}
|
||||
|
||||
// Assign group names and record can_parallel relationships
|
||||
for _, members := range groups {
|
||||
if len(members) > 1 {
|
||||
groupCounter++
|
||||
groupName := fmt.Sprintf("group-%d", groupCounter)
|
||||
analysis.ParallelGroups[groupName] = members
|
||||
|
||||
// Update each step's parallel info
|
||||
for _, id := range members {
|
||||
info := analysis.Steps[id]
|
||||
info.ParallelGroup = groupName
|
||||
// Record all other members as can_parallel
|
||||
for _, otherId := range members {
|
||||
if otherId != id {
|
||||
info.CanParallel = append(info.CanParallel, otherId)
|
||||
}
|
||||
}
|
||||
sort.Strings(info.CanParallel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return analysis
|
||||
}
|
||||
|
||||
// calculateBlockingDepths calculates the "blocking depth" of each step.
|
||||
// Depth 0 = no blockers, Depth 1 = blocked by depth-0 steps, etc.
|
||||
func calculateBlockingDepths(subgraph *MoleculeSubgraph, blockedBy map[string]map[string]bool) map[string]int {
|
||||
depths := make(map[string]int)
|
||||
visited := make(map[string]bool)
|
||||
|
||||
var calculateDepth func(id string) int
|
||||
calculateDepth = func(id string) int {
|
||||
if d, ok := depths[id]; ok {
|
||||
return d
|
||||
}
|
||||
if visited[id] {
|
||||
// Cycle detected, return 0 to break
|
||||
return 0
|
||||
}
|
||||
visited[id] = true
|
||||
|
||||
maxBlockerDepth := -1
|
||||
for blockerID := range blockedBy[id] {
|
||||
// Only count open blockers
|
||||
blocker := subgraph.IssueMap[blockerID]
|
||||
if blocker != nil && blocker.Status != types.StatusClosed {
|
||||
blockerDepth := calculateDepth(blockerID)
|
||||
if blockerDepth > maxBlockerDepth {
|
||||
maxBlockerDepth = blockerDepth
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
depth := maxBlockerDepth + 1
|
||||
depths[id] = depth
|
||||
return depth
|
||||
}
|
||||
|
||||
for _, issue := range subgraph.Issues {
|
||||
calculateDepth(issue.ID)
|
||||
}
|
||||
|
||||
return depths
|
||||
}
|
||||
|
||||
// showMoleculeWithParallel displays molecule structure with parallel annotations
|
||||
func showMoleculeWithParallel(subgraph *MoleculeSubgraph) {
|
||||
analysis := analyzeMoleculeParallel(subgraph)
|
||||
|
||||
if jsonOutput {
|
||||
outputJSON(map[string]interface{}{
|
||||
"root": subgraph.Root,
|
||||
"issues": subgraph.Issues,
|
||||
"dependencies": subgraph.Dependencies,
|
||||
"variables": extractAllVariables(subgraph),
|
||||
"parallel": analysis,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("\n%s Molecule: %s\n", ui.RenderAccent("🧪"), subgraph.Root.Title)
|
||||
fmt.Printf(" ID: %s\n", subgraph.Root.ID)
|
||||
fmt.Printf(" Steps: %d (%d ready)\n", analysis.TotalSteps, analysis.ReadySteps)
|
||||
|
||||
// Show parallel groups summary
|
||||
if len(analysis.ParallelGroups) > 0 {
|
||||
fmt.Printf("\n%s Parallel Groups:\n", ui.RenderPass("⚡"))
|
||||
for groupName, members := range analysis.ParallelGroups {
|
||||
fmt.Printf(" %s: %s\n", groupName, strings.Join(members, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
vars := extractAllVariables(subgraph)
|
||||
if len(vars) > 0 {
|
||||
fmt.Printf("\n%s Variables:\n", ui.RenderWarn("📝"))
|
||||
for _, v := range vars {
|
||||
fmt.Printf(" {{%s}}\n", v)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("\n%s Structure:\n", ui.RenderPass("🌲"))
|
||||
printMoleculeTreeWithParallel(subgraph, analysis, subgraph.Root.ID, 0, true)
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
// printMoleculeTreeWithParallel prints the molecule structure with parallel annotations
|
||||
func printMoleculeTreeWithParallel(subgraph *MoleculeSubgraph, analysis *ParallelAnalysis, parentID string, depth int, isRoot bool) {
|
||||
indent := strings.Repeat(" ", depth)
|
||||
|
||||
// Print root with parallel info
|
||||
if isRoot {
|
||||
rootInfo := analysis.Steps[subgraph.Root.ID]
|
||||
annotation := getParallelAnnotation(rootInfo)
|
||||
fmt.Printf("%s %s%s\n", indent, subgraph.Root.Title, annotation)
|
||||
}
|
||||
|
||||
// Find children of this parent
|
||||
var children []*types.Issue
|
||||
for _, dep := range subgraph.Dependencies {
|
||||
if dep.DependsOnID == parentID && dep.Type == types.DepParentChild {
|
||||
if child, ok := subgraph.IssueMap[dep.IssueID]; ok {
|
||||
children = append(children, child)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Print children
|
||||
for i, child := range children {
|
||||
connector := "├──"
|
||||
if i == len(children)-1 {
|
||||
connector = "└──"
|
||||
}
|
||||
|
||||
info := analysis.Steps[child.ID]
|
||||
annotation := getParallelAnnotation(info)
|
||||
|
||||
fmt.Printf("%s %s %s%s\n", indent, connector, child.Title, annotation)
|
||||
printMoleculeTreeWithParallel(subgraph, analysis, child.ID, depth+1, false)
|
||||
}
|
||||
}
|
||||
|
||||
// getParallelAnnotation returns the annotation string for a step's parallel status
|
||||
func getParallelAnnotation(info *ParallelInfo) string {
|
||||
if info == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
parts := []string{}
|
||||
|
||||
// Status indicator
|
||||
switch info.Status {
|
||||
case string(types.StatusOpen):
|
||||
if info.IsReady {
|
||||
parts = append(parts, ui.RenderPass("ready"))
|
||||
} else {
|
||||
parts = append(parts, ui.RenderFail("blocked"))
|
||||
}
|
||||
case string(types.StatusInProgress):
|
||||
parts = append(parts, ui.RenderWarn("in_progress"))
|
||||
case string(types.StatusClosed):
|
||||
parts = append(parts, ui.RenderPass("completed"))
|
||||
}
|
||||
|
||||
// Parallel group
|
||||
if info.ParallelGroup != "" {
|
||||
parts = append(parts, ui.RenderAccent(info.ParallelGroup))
|
||||
}
|
||||
|
||||
// Blocking info
|
||||
if len(info.BlockedBy) > 0 {
|
||||
parts = append(parts, fmt.Sprintf("needs: %s", strings.Join(info.BlockedBy, ", ")))
|
||||
}
|
||||
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
}
|
||||
return " [" + strings.Join(parts, " | ") + "]"
|
||||
}
|
||||
|
||||
func init() {
|
||||
molShowCmd.Flags().BoolVarP(&molShowParallel, "parallel", "p", false, "Show parallel step analysis")
|
||||
molCmd.AddCommand(molShowCmd)
|
||||
}
|
||||
|
||||
@@ -2345,3 +2345,304 @@ func TestBondProtoMolMultipleArms(t *testing.T) {
|
||||
t.Errorf("Nux issue not found: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Parallel Detection Tests (bd-xo1o.4)
|
||||
// =============================================================================
|
||||
|
||||
// TestAnalyzeMoleculeParallelNoBlocking tests parallel detection with no blocking deps
|
||||
func TestAnalyzeMoleculeParallelNoBlocking(t *testing.T) {
|
||||
// Create a simple molecule with parallel children (no blocking deps between them)
|
||||
root := &types.Issue{
|
||||
ID: "mol-test",
|
||||
Title: "Test Molecule",
|
||||
Status: types.StatusOpen,
|
||||
IssueType: types.TypeEpic,
|
||||
}
|
||||
child1 := &types.Issue{
|
||||
ID: "mol-test.step1",
|
||||
Title: "Step 1",
|
||||
Status: types.StatusOpen,
|
||||
IssueType: types.TypeTask,
|
||||
}
|
||||
child2 := &types.Issue{
|
||||
ID: "mol-test.step2",
|
||||
Title: "Step 2",
|
||||
Status: types.StatusOpen,
|
||||
IssueType: types.TypeTask,
|
||||
}
|
||||
|
||||
subgraph := &MoleculeSubgraph{
|
||||
Root: root,
|
||||
Issues: []*types.Issue{root, child1, child2},
|
||||
IssueMap: map[string]*types.Issue{
|
||||
root.ID: root,
|
||||
child1.ID: child1,
|
||||
child2.ID: child2,
|
||||
},
|
||||
Dependencies: []*types.Dependency{
|
||||
{IssueID: child1.ID, DependsOnID: root.ID, Type: types.DepParentChild},
|
||||
{IssueID: child2.ID, DependsOnID: root.ID, Type: types.DepParentChild},
|
||||
},
|
||||
}
|
||||
|
||||
analysis := analyzeMoleculeParallel(subgraph)
|
||||
|
||||
// All 3 should be ready (root + 2 children with no blocking deps)
|
||||
if analysis.ReadySteps != 3 {
|
||||
t.Errorf("ReadySteps = %d, want 3", analysis.ReadySteps)
|
||||
}
|
||||
|
||||
// Children should be in the same parallel group
|
||||
step1Info := analysis.Steps[child1.ID]
|
||||
step2Info := analysis.Steps[child2.ID]
|
||||
|
||||
if step1Info.ParallelGroup == "" {
|
||||
t.Error("Step1 should be in a parallel group")
|
||||
}
|
||||
if step1Info.ParallelGroup != step2Info.ParallelGroup {
|
||||
t.Errorf("Step1 and Step2 should be in same parallel group: %s vs %s",
|
||||
step1Info.ParallelGroup, step2Info.ParallelGroup)
|
||||
}
|
||||
|
||||
// Check can_parallel
|
||||
found := false
|
||||
for _, id := range step1Info.CanParallel {
|
||||
if id == child2.ID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("Step1.CanParallel should contain Step2.ID")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAnalyzeMoleculeParallelWithBlocking tests parallel detection with blocking deps
|
||||
func TestAnalyzeMoleculeParallelWithBlocking(t *testing.T) {
|
||||
// Create a sequential molecule: step1 blocks step2
|
||||
root := &types.Issue{
|
||||
ID: "mol-seq",
|
||||
Title: "Sequential Molecule",
|
||||
Status: types.StatusOpen,
|
||||
IssueType: types.TypeEpic,
|
||||
}
|
||||
step1 := &types.Issue{
|
||||
ID: "mol-seq.step1",
|
||||
Title: "Step 1",
|
||||
Status: types.StatusOpen,
|
||||
IssueType: types.TypeTask,
|
||||
}
|
||||
step2 := &types.Issue{
|
||||
ID: "mol-seq.step2",
|
||||
Title: "Step 2 (blocked by Step 1)",
|
||||
Status: types.StatusOpen,
|
||||
IssueType: types.TypeTask,
|
||||
}
|
||||
|
||||
subgraph := &MoleculeSubgraph{
|
||||
Root: root,
|
||||
Issues: []*types.Issue{root, step1, step2},
|
||||
IssueMap: map[string]*types.Issue{
|
||||
root.ID: root,
|
||||
step1.ID: step1,
|
||||
step2.ID: step2,
|
||||
},
|
||||
Dependencies: []*types.Dependency{
|
||||
{IssueID: step1.ID, DependsOnID: root.ID, Type: types.DepParentChild},
|
||||
{IssueID: step2.ID, DependsOnID: root.ID, Type: types.DepParentChild},
|
||||
{IssueID: step2.ID, DependsOnID: step1.ID, Type: types.DepBlocks}, // step2 blocked by step1
|
||||
},
|
||||
}
|
||||
|
||||
analysis := analyzeMoleculeParallel(subgraph)
|
||||
|
||||
// Only root and step1 should be ready (step2 is blocked)
|
||||
if analysis.ReadySteps != 2 {
|
||||
t.Errorf("ReadySteps = %d, want 2 (step2 blocked)", analysis.ReadySteps)
|
||||
}
|
||||
|
||||
step1Info := analysis.Steps[step1.ID]
|
||||
step2Info := analysis.Steps[step2.ID]
|
||||
|
||||
if !step1Info.IsReady {
|
||||
t.Error("Step1 should be ready")
|
||||
}
|
||||
if step2Info.IsReady {
|
||||
t.Error("Step2 should NOT be ready (blocked by step1)")
|
||||
}
|
||||
if len(step2Info.BlockedBy) != 1 || step2Info.BlockedBy[0] != step1.ID {
|
||||
t.Errorf("Step2.BlockedBy = %v, want [%s]", step2Info.BlockedBy, step1.ID)
|
||||
}
|
||||
|
||||
// Step1 and Step2 should NOT be in the same parallel group
|
||||
if step1Info.ParallelGroup != "" && step1Info.ParallelGroup == step2Info.ParallelGroup {
|
||||
t.Error("Blocking steps should NOT be in the same parallel group")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAnalyzeMoleculeParallelCompletedBlockers tests that completed steps don't block
|
||||
func TestAnalyzeMoleculeParallelCompletedBlockers(t *testing.T) {
|
||||
// Create molecule where step1 is completed, so step2 should be ready
|
||||
root := &types.Issue{
|
||||
ID: "mol-done",
|
||||
Title: "Molecule with completed step",
|
||||
Status: types.StatusOpen,
|
||||
IssueType: types.TypeEpic,
|
||||
}
|
||||
step1 := &types.Issue{
|
||||
ID: "mol-done.step1",
|
||||
Title: "Step 1 (completed)",
|
||||
Status: types.StatusClosed, // Completed!
|
||||
IssueType: types.TypeTask,
|
||||
}
|
||||
step2 := &types.Issue{
|
||||
ID: "mol-done.step2",
|
||||
Title: "Step 2 (depends on step1)",
|
||||
Status: types.StatusOpen,
|
||||
IssueType: types.TypeTask,
|
||||
}
|
||||
|
||||
subgraph := &MoleculeSubgraph{
|
||||
Root: root,
|
||||
Issues: []*types.Issue{root, step1, step2},
|
||||
IssueMap: map[string]*types.Issue{
|
||||
root.ID: root,
|
||||
step1.ID: step1,
|
||||
step2.ID: step2,
|
||||
},
|
||||
Dependencies: []*types.Dependency{
|
||||
{IssueID: step1.ID, DependsOnID: root.ID, Type: types.DepParentChild},
|
||||
{IssueID: step2.ID, DependsOnID: root.ID, Type: types.DepParentChild},
|
||||
{IssueID: step2.ID, DependsOnID: step1.ID, Type: types.DepBlocks},
|
||||
},
|
||||
}
|
||||
|
||||
analysis := analyzeMoleculeParallel(subgraph)
|
||||
|
||||
step2Info := analysis.Steps[step2.ID]
|
||||
|
||||
// Step2 should be ready since step1 is closed
|
||||
if !step2Info.IsReady {
|
||||
t.Error("Step2 should be ready (step1 is completed)")
|
||||
}
|
||||
if len(step2Info.BlockedBy) != 0 {
|
||||
t.Errorf("Step2.BlockedBy = %v, want empty (step1 completed)", step2Info.BlockedBy)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAnalyzeMoleculeParallelMultipleArms tests parallel detection across bonded arms
|
||||
func TestAnalyzeMoleculeParallelMultipleArms(t *testing.T) {
|
||||
// Create molecule with two arms that can run in parallel
|
||||
root := &types.Issue{
|
||||
ID: "patrol",
|
||||
Title: "Patrol",
|
||||
Status: types.StatusOpen,
|
||||
IssueType: types.TypeEpic,
|
||||
}
|
||||
armAce := &types.Issue{
|
||||
ID: "patrol.arm-ace",
|
||||
Title: "Arm: ace",
|
||||
Status: types.StatusOpen,
|
||||
IssueType: types.TypeTask,
|
||||
}
|
||||
armNux := &types.Issue{
|
||||
ID: "patrol.arm-nux",
|
||||
Title: "Arm: nux",
|
||||
Status: types.StatusOpen,
|
||||
IssueType: types.TypeTask,
|
||||
}
|
||||
|
||||
subgraph := &MoleculeSubgraph{
|
||||
Root: root,
|
||||
Issues: []*types.Issue{root, armAce, armNux},
|
||||
IssueMap: map[string]*types.Issue{
|
||||
root.ID: root,
|
||||
armAce.ID: armAce,
|
||||
armNux.ID: armNux,
|
||||
},
|
||||
Dependencies: []*types.Dependency{
|
||||
{IssueID: armAce.ID, DependsOnID: root.ID, Type: types.DepParentChild},
|
||||
{IssueID: armNux.ID, DependsOnID: root.ID, Type: types.DepParentChild},
|
||||
// No blocking deps between arms
|
||||
},
|
||||
}
|
||||
|
||||
analysis := analyzeMoleculeParallel(subgraph)
|
||||
|
||||
// All 3 should be ready
|
||||
if analysis.ReadySteps != 3 {
|
||||
t.Errorf("ReadySteps = %d, want 3", analysis.ReadySteps)
|
||||
}
|
||||
|
||||
// Arms should be in the same parallel group
|
||||
aceInfo := analysis.Steps[armAce.ID]
|
||||
nuxInfo := analysis.Steps[armNux.ID]
|
||||
|
||||
if aceInfo.ParallelGroup == "" {
|
||||
t.Error("arm-ace should be in a parallel group")
|
||||
}
|
||||
if aceInfo.ParallelGroup != nuxInfo.ParallelGroup {
|
||||
t.Errorf("Arms should be in same parallel group: %s vs %s",
|
||||
aceInfo.ParallelGroup, nuxInfo.ParallelGroup)
|
||||
}
|
||||
|
||||
// Should have at least one parallel group with both arms
|
||||
foundGroup := false
|
||||
for _, members := range analysis.ParallelGroups {
|
||||
hasAce := false
|
||||
hasNux := false
|
||||
for _, id := range members {
|
||||
if id == armAce.ID {
|
||||
hasAce = true
|
||||
}
|
||||
if id == armNux.ID {
|
||||
hasNux = true
|
||||
}
|
||||
}
|
||||
if hasAce && hasNux {
|
||||
foundGroup = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundGroup {
|
||||
t.Error("Should have a parallel group containing both arms")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCalculateBlockingDepths tests the depth calculation
|
||||
func TestCalculateBlockingDepths(t *testing.T) {
|
||||
// Create chain: root -> step1 -> step2 -> step3
|
||||
root := &types.Issue{ID: "root", Status: types.StatusOpen}
|
||||
step1 := &types.Issue{ID: "step1", Status: types.StatusOpen}
|
||||
step2 := &types.Issue{ID: "step2", Status: types.StatusOpen}
|
||||
step3 := &types.Issue{ID: "step3", Status: types.StatusOpen}
|
||||
|
||||
subgraph := &MoleculeSubgraph{
|
||||
Root: root,
|
||||
Issues: []*types.Issue{root, step1, step2, step3},
|
||||
IssueMap: map[string]*types.Issue{"root": root, "step1": step1, "step2": step2, "step3": step3},
|
||||
}
|
||||
|
||||
blockedBy := map[string]map[string]bool{
|
||||
"root": {},
|
||||
"step1": {"root": true},
|
||||
"step2": {"step1": true},
|
||||
"step3": {"step2": true},
|
||||
}
|
||||
|
||||
depths := calculateBlockingDepths(subgraph, blockedBy)
|
||||
|
||||
if depths["root"] != 0 {
|
||||
t.Errorf("root depth = %d, want 0", depths["root"])
|
||||
}
|
||||
if depths["step1"] != 1 {
|
||||
t.Errorf("step1 depth = %d, want 1", depths["step1"])
|
||||
}
|
||||
if depths["step2"] != 2 {
|
||||
t.Errorf("step2 depth = %d, want 2", depths["step2"])
|
||||
}
|
||||
if depths["step3"] != 3 {
|
||||
t.Errorf("step3 depth = %d, want 3", depths["step3"])
|
||||
}
|
||||
}
|
||||
|
||||
149
cmd/bd/ready.go
149
cmd/bd/ready.go
@@ -12,11 +12,26 @@ import (
|
||||
"github.com/steveyegge/beads/internal/types"
|
||||
"github.com/steveyegge/beads/internal/ui"
|
||||
"github.com/steveyegge/beads/internal/util"
|
||||
"github.com/steveyegge/beads/internal/utils"
|
||||
)
|
||||
|
||||
var readyCmd = &cobra.Command{
|
||||
Use: "ready",
|
||||
Short: "Show ready work (no blockers, open or in_progress)",
|
||||
Long: `Show ready work (issues with no blockers that are open or in_progress).
|
||||
|
||||
Use --mol to filter to a specific molecule's steps:
|
||||
bd ready --mol bd-patrol # Show ready steps within molecule
|
||||
|
||||
This is useful for agents executing molecules to see which steps can run next.`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
// Handle molecule-specific ready query
|
||||
molID, _ := cmd.Flags().GetString("mol")
|
||||
if molID != "" {
|
||||
runMoleculeReady(cmd, molID)
|
||||
return
|
||||
}
|
||||
|
||||
limit, _ := cmd.Flags().GetInt("limit")
|
||||
assignee, _ := cmd.Flags().GetString("assignee")
|
||||
unassigned, _ := cmd.Flags().GetBool("unassigned")
|
||||
@@ -252,6 +267,139 @@ var blockedCmd = &cobra.Command{
|
||||
},
|
||||
}
|
||||
|
||||
// runMoleculeReady shows ready steps within a specific molecule
|
||||
func runMoleculeReady(cmd *cobra.Command, molIDArg string) {
|
||||
ctx := rootCtx
|
||||
|
||||
// Molecule-ready requires direct store access for subgraph loading
|
||||
if store == nil {
|
||||
if daemonClient != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: bd ready --mol requires direct database access\n")
|
||||
fmt.Fprintf(os.Stderr, "Hint: use --no-daemon flag: bd --no-daemon ready --mol %s\n", molIDArg)
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "Error: no database connection\n")
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Resolve molecule ID
|
||||
moleculeID, err := utils.ResolvePartialID(ctx, store, molIDArg)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: molecule '%s' not found\n", molIDArg)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Load molecule subgraph
|
||||
subgraph, err := loadTemplateSubgraph(ctx, store, moleculeID)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error loading molecule: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Get parallel analysis to find ready steps
|
||||
analysis := analyzeMoleculeParallel(subgraph)
|
||||
|
||||
// Collect ready steps
|
||||
var readySteps []*MoleculeReadyStep
|
||||
for _, issue := range subgraph.Issues {
|
||||
info := analysis.Steps[issue.ID]
|
||||
if info != nil && info.IsReady {
|
||||
readySteps = append(readySteps, &MoleculeReadyStep{
|
||||
Issue: issue,
|
||||
ParallelInfo: info,
|
||||
ParallelGroup: info.ParallelGroup,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if jsonOutput {
|
||||
output := MoleculeReadyOutput{
|
||||
MoleculeID: moleculeID,
|
||||
MoleculeTitle: subgraph.Root.Title,
|
||||
TotalSteps: analysis.TotalSteps,
|
||||
ReadySteps: len(readySteps),
|
||||
Steps: readySteps,
|
||||
ParallelGroups: analysis.ParallelGroups,
|
||||
}
|
||||
outputJSON(output)
|
||||
return
|
||||
}
|
||||
|
||||
// Human-readable output
|
||||
fmt.Printf("\n%s Ready steps in molecule: %s\n", ui.RenderAccent("🧪"), subgraph.Root.Title)
|
||||
fmt.Printf(" ID: %s\n", moleculeID)
|
||||
fmt.Printf(" Total: %d steps, %d ready\n", analysis.TotalSteps, len(readySteps))
|
||||
|
||||
if len(readySteps) == 0 {
|
||||
fmt.Printf("\n%s No ready steps (all blocked or completed)\n\n", ui.RenderWarn("✨"))
|
||||
return
|
||||
}
|
||||
|
||||
// Show parallel groups if any
|
||||
if len(analysis.ParallelGroups) > 0 {
|
||||
fmt.Printf("\n%s Parallel Groups:\n", ui.RenderPass("⚡"))
|
||||
for groupName, members := range analysis.ParallelGroups {
|
||||
// Check if any members are ready
|
||||
readyInGroup := 0
|
||||
for _, id := range members {
|
||||
if info := analysis.Steps[id]; info != nil && info.IsReady {
|
||||
readyInGroup++
|
||||
}
|
||||
}
|
||||
if readyInGroup > 0 {
|
||||
fmt.Printf(" %s: %d ready\n", groupName, readyInGroup)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("\n%s Ready steps:\n\n", ui.RenderPass("📋"))
|
||||
for i, step := range readySteps {
|
||||
// Show parallel group if in one
|
||||
groupAnnotation := ""
|
||||
if step.ParallelGroup != "" {
|
||||
groupAnnotation = fmt.Sprintf(" [%s]", ui.RenderAccent(step.ParallelGroup))
|
||||
}
|
||||
|
||||
fmt.Printf("%d. [%s] [%s] %s: %s%s\n", i+1,
|
||||
ui.RenderPriority(step.Issue.Priority),
|
||||
ui.RenderType(string(step.Issue.IssueType)),
|
||||
ui.RenderID(step.Issue.ID),
|
||||
step.Issue.Title,
|
||||
groupAnnotation)
|
||||
|
||||
// Show what this step can parallelize with
|
||||
if len(step.ParallelInfo.CanParallel) > 0 {
|
||||
readyParallel := []string{}
|
||||
for _, pID := range step.ParallelInfo.CanParallel {
|
||||
if pInfo := analysis.Steps[pID]; pInfo != nil && pInfo.IsReady {
|
||||
readyParallel = append(readyParallel, pID)
|
||||
}
|
||||
}
|
||||
if len(readyParallel) > 0 {
|
||||
fmt.Printf(" Can run with: %v\n", readyParallel)
|
||||
}
|
||||
}
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
// MoleculeReadyStep holds a ready step with its parallel info
|
||||
type MoleculeReadyStep struct {
|
||||
Issue *types.Issue `json:"issue"`
|
||||
ParallelInfo *ParallelInfo `json:"parallel_info"`
|
||||
ParallelGroup string `json:"parallel_group,omitempty"`
|
||||
}
|
||||
|
||||
// MoleculeReadyOutput is the JSON output for bd ready --mol
|
||||
type MoleculeReadyOutput struct {
|
||||
MoleculeID string `json:"molecule_id"`
|
||||
MoleculeTitle string `json:"molecule_title"`
|
||||
TotalSteps int `json:"total_steps"`
|
||||
ReadySteps int `json:"ready_steps"`
|
||||
Steps []*MoleculeReadyStep `json:"steps"`
|
||||
ParallelGroups map[string][]string `json:"parallel_groups"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
readyCmd.Flags().IntP("limit", "n", 10, "Maximum issues to show")
|
||||
readyCmd.Flags().IntP("priority", "p", 0, "Filter by priority")
|
||||
@@ -261,6 +409,7 @@ func init() {
|
||||
readyCmd.Flags().StringSliceP("label", "l", []string{}, "Filter by labels (AND: must have ALL). Can combine with --label-any")
|
||||
readyCmd.Flags().StringSlice("label-any", []string{}, "Filter by labels (OR: must have AT LEAST ONE). Can combine with --label")
|
||||
readyCmd.Flags().StringP("type", "t", "", "Filter by issue type (task, bug, feature, epic, merge-request)")
|
||||
readyCmd.Flags().String("mol", "", "Filter to steps within a specific molecule")
|
||||
rootCmd.AddCommand(readyCmd)
|
||||
rootCmd.AddCommand(blockedCmd)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user