fix: mol run loads all hierarchical children + supports title lookup (bd-c8d5, bd-drcx)

Two issues fixed:

1. bd-c8d5: mol run only created partial children from proto
   - Root cause: children with missing/wrong dependencies were not loaded
   - Fix: loadDescendants now uses two strategies:
     - Strategy 1: Check dependency records for parent-child relationships
     - Strategy 2: Find hierarchical children by ID pattern (parent.N)
   - This catches children that may have broken dependency data

2. bd-drcx: mol run now supports proto lookup by title
   - Can use: bd mol run mol-polecat-work --var issue=gt-xxx
   - Or by ID: bd mol run gt-lwuu --var issue=gt-xxx
   - Title matching is case-insensitive and supports partial matches
   - Shows helpful error on ambiguous matches

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-12-24 23:33:14 -08:00
parent 81171ff237
commit c429405e0d
3 changed files with 495 additions and 8 deletions

View File

@@ -10,11 +10,10 @@ import (
"github.com/steveyegge/beads/internal/storage/sqlite" "github.com/steveyegge/beads/internal/storage/sqlite"
"github.com/steveyegge/beads/internal/types" "github.com/steveyegge/beads/internal/types"
"github.com/steveyegge/beads/internal/ui" "github.com/steveyegge/beads/internal/ui"
"github.com/steveyegge/beads/internal/utils"
) )
var molRunCmd = &cobra.Command{ var molRunCmd = &cobra.Command{
Use: "run <proto-id>", Use: "run <proto-id-or-title>",
Short: "Spawn proto and start execution (spawn + assign + pin)", Short: "Spawn proto and start execution (spawn + assign + pin)",
Long: `Run a molecule by spawning a proto and setting up for durable execution. Long: `Run a molecule by spawning a proto and setting up for durable execution.
@@ -24,6 +23,9 @@ This command:
3. Sets root status to in_progress 3. Sets root status to in_progress
4. Pins the root issue for session recovery 4. Pins the root issue for session recovery
The proto can be specified by ID or title. Title matching is case-insensitive
and supports partial matches (e.g., "polecat" matches "mol-polecat-work").
After a crash or session reset, the pinned root issue ensures the agent After a crash or session reset, the pinned root issue ensures the agent
can resume from where it left off by checking 'bd ready'. can resume from where it left off by checking 'bd ready'.
@@ -33,8 +35,9 @@ This is essential for wisp molecule spawning where templates exist in the main
database but instances should be ephemeral. database but instances should be ephemeral.
Example: Example:
bd mol run mol-version-bump --var version=1.2.0 bd mol run mol-polecat-work --var issue=gt-xxx # By title
bd mol run bd-qqc --var version=0.32.0 --var date=2025-01-01 bd mol run gt-lwuu --var issue=gt-xxx # By ID
bd mol run polecat --var issue=gt-xxx # By partial title
bd --db .beads-wisp/beads.db mol run mol-patrol --template-db .beads/beads.db`, bd --db .beads-wisp/beads.db mol run mol-patrol --template-db .beads/beads.db`,
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
Run: runMolRun, Run: runMolRun,
@@ -97,10 +100,10 @@ func runMolRun(cmd *cobra.Command, args []string) {
defer func() { _ = templateStore.Close() }() defer func() { _ = templateStore.Close() }()
} }
// Resolve molecule ID from template store // Resolve molecule ID from template store (supports both ID and title - bd-drcx)
moleculeID, err := utils.ResolvePartialID(ctx, templateStore, args[0]) moleculeID, err := resolveProtoIDOrTitle(ctx, templateStore, args[0])
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Error resolving molecule ID %s: %v\n", args[0], err) fmt.Fprintf(os.Stderr, "Error resolving molecule %s: %v\n", args[0], err)
os.Exit(1) os.Exit(1)
} }

View File

@@ -409,8 +409,14 @@ func loadTemplateSubgraph(ctx context.Context, s storage.Storage, templateID str
} }
// loadDescendants recursively loads all child issues // loadDescendants recursively loads all child issues
// It uses two strategies to find children:
// 1. Check dependency records for parent-child relationships
// 2. Check for hierarchical IDs (parent.N) to catch children with missing/wrong deps (bd-c8d5)
func loadDescendants(ctx context.Context, s storage.Storage, subgraph *TemplateSubgraph, parentID string) error { func loadDescendants(ctx context.Context, s storage.Storage, subgraph *TemplateSubgraph, parentID string) error {
// GetDependents returns issues that depend on parentID // Track children we've already added to avoid duplicates
addedChildren := make(map[string]bool)
// Strategy 1: GetDependents returns issues that depend on parentID
dependents, err := s.GetDependents(ctx, parentID) dependents, err := s.GetDependents(ctx, parentID)
if err != nil { if err != nil {
return fmt.Errorf("failed to get dependents of %s: %w", parentID, err) return fmt.Errorf("failed to get dependents of %s: %w", parentID, err)
@@ -443,6 +449,7 @@ func loadDescendants(ctx context.Context, s storage.Storage, subgraph *TemplateS
// Add to subgraph // Add to subgraph
subgraph.Issues = append(subgraph.Issues, dependent) subgraph.Issues = append(subgraph.Issues, dependent)
subgraph.IssueMap[dependent.ID] = dependent subgraph.IssueMap[dependent.ID] = dependent
addedChildren[dependent.ID] = true
// Recurse to get children of this child // Recurse to get children of this child
if err := loadDescendants(ctx, s, subgraph, dependent.ID); err != nil { if err := loadDescendants(ctx, s, subgraph, dependent.ID); err != nil {
@@ -450,9 +457,132 @@ func loadDescendants(ctx context.Context, s storage.Storage, subgraph *TemplateS
} }
} }
// Strategy 2: Find hierarchical children by ID pattern (bd-c8d5)
// This catches children that have missing or incorrect dependency types.
// Hierarchical IDs follow the pattern: parentID.N (e.g., "gt-abc.1", "gt-abc.2")
hierarchicalChildren, err := findHierarchicalChildren(ctx, s, parentID)
if err != nil {
// Non-fatal: continue with what we have
return nil
}
for _, child := range hierarchicalChildren {
if addedChildren[child.ID] {
continue // Already added via dependency
}
if _, exists := subgraph.IssueMap[child.ID]; exists {
continue // Already in subgraph
}
// Add to subgraph
subgraph.Issues = append(subgraph.Issues, child)
subgraph.IssueMap[child.ID] = child
addedChildren[child.ID] = true
// Recurse to get children of this child
if err := loadDescendants(ctx, s, subgraph, child.ID); err != nil {
return err
}
}
return nil return nil
} }
// findHierarchicalChildren finds issues with IDs that match the pattern parentID.N
// This catches hierarchical children that may be missing parent-child dependencies.
func findHierarchicalChildren(ctx context.Context, s storage.Storage, parentID string) ([]*types.Issue, error) {
// Look for issues with IDs starting with "parentID."
// We need to query by ID pattern, which requires listing issues
pattern := parentID + "."
// Use the storage's search capability with a filter
allIssues, err := s.SearchIssues(ctx, "", types.IssueFilter{})
if err != nil {
return nil, err
}
var children []*types.Issue
for _, issue := range allIssues {
// Check if ID starts with pattern and is a direct child (no further dots after the pattern)
if len(issue.ID) > len(pattern) && issue.ID[:len(pattern)] == pattern {
// Check it's a direct child, not a grandchild
// e.g., "parent.1" is a child, "parent.1.2" is a grandchild
remaining := issue.ID[len(pattern):]
if !strings.Contains(remaining, ".") {
children = append(children, issue)
}
}
}
return children, nil
}
// =============================================================================
// Proto Lookup Functions (bd-drcx)
// =============================================================================
// resolveProtoIDOrTitle resolves a proto by ID or title.
// It first tries to resolve as an ID (via ResolvePartialID).
// If that fails, it searches for protos with matching titles.
// Returns the proto ID if found, or an error if not found or ambiguous.
func resolveProtoIDOrTitle(ctx context.Context, s storage.Storage, input string) (string, error) {
// Strategy 1: Try to resolve as an ID
protoID, err := utils.ResolvePartialID(ctx, s, input)
if err == nil {
// Verify it's a proto (has template label)
issue, getErr := s.GetIssue(ctx, protoID)
if getErr == nil && issue != nil {
labels, _ := s.GetLabels(ctx, protoID)
for _, label := range labels {
if label == BeadsTemplateLabel {
return protoID, nil // Found a valid proto by ID
}
}
}
// ID resolved but not a proto - continue to title search
}
// Strategy 2: Search for protos by title
protos, err := s.GetIssuesByLabel(ctx, BeadsTemplateLabel)
if err != nil {
return "", fmt.Errorf("failed to search protos: %w", err)
}
var matches []*types.Issue
var exactMatch *types.Issue
for _, proto := range protos {
// Check for exact title match (case-insensitive)
if strings.EqualFold(proto.Title, input) {
exactMatch = proto
break
}
// Check for partial title match (case-insensitive)
if strings.Contains(strings.ToLower(proto.Title), strings.ToLower(input)) {
matches = append(matches, proto)
}
}
if exactMatch != nil {
return exactMatch.ID, nil
}
if len(matches) == 0 {
return "", fmt.Errorf("no proto found matching %q (by ID or title)", input)
}
if len(matches) == 1 {
return matches[0].ID, nil
}
// Multiple matches - show them all for disambiguation
var matchNames []string
for _, m := range matches {
matchNames = append(matchNames, fmt.Sprintf("%s: %s", m.ID, m.Title))
}
return "", fmt.Errorf("ambiguous: %q matches %d protos:\n %s\nUse the ID or a more specific title", input, len(matches), strings.Join(matchNames, "\n "))
}
// ============================================================================= // =============================================================================
// Daemon-compatible Template Functions // Daemon-compatible Template Functions
// ============================================================================= // =============================================================================

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"github.com/steveyegge/beads/internal/storage/sqlite" "github.com/steveyegge/beads/internal/storage/sqlite"
@@ -474,3 +475,356 @@ func TestExtractAllVariables(t *testing.T) {
t.Error("Missing variable: environment") t.Error("Missing variable: environment")
} }
} }
// createIssueWithID creates an issue with a specific ID (for testing hierarchical IDs)
func (h *templateTestHelper) createIssueWithID(id, title, description string, issueType types.IssueType, priority int) *types.Issue {
issue := &types.Issue{
ID: id,
Title: title,
Description: description,
Priority: priority,
IssueType: issueType,
Status: types.StatusOpen,
}
if err := h.s.CreateIssue(h.ctx, issue, "test-user"); err != nil {
h.t.Fatalf("Failed to create issue with ID %s: %v", id, err)
}
return issue
}
// TestResolveProtoIDOrTitle tests proto lookup by ID or title (bd-drcx)
func TestResolveProtoIDOrTitle(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "bd-test-proto-lookup-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
testDB := filepath.Join(tmpDir, "test.db")
s := newTestStore(t, testDB)
defer s.Close()
ctx := context.Background()
h := &templateTestHelper{s: s, ctx: ctx, t: t}
// Create some protos with distinct titles
proto1 := h.createIssue("mol-polecat-work", "Polecat workflow", types.TypeEpic, 1)
h.addLabel(proto1.ID, BeadsTemplateLabel)
proto2 := h.createIssue("mol-version-bump", "Version bump workflow", types.TypeEpic, 1)
h.addLabel(proto2.ID, BeadsTemplateLabel)
proto3 := h.createIssue("mol-release", "Release workflow", types.TypeEpic, 1)
h.addLabel(proto3.ID, BeadsTemplateLabel)
// Create a non-proto issue with similar title
nonProto := h.createIssue("mol-test", "Not a proto", types.TypeTask, 2)
_ = nonProto
t.Run("resolve by exact ID", func(t *testing.T) {
resolved, err := resolveProtoIDOrTitle(ctx, s, proto1.ID)
if err != nil {
t.Fatalf("Failed to resolve by ID: %v", err)
}
if resolved != proto1.ID {
t.Errorf("Expected %s, got %s", proto1.ID, resolved)
}
})
t.Run("resolve by exact title", func(t *testing.T) {
resolved, err := resolveProtoIDOrTitle(ctx, s, "mol-polecat-work")
if err != nil {
t.Fatalf("Failed to resolve by title: %v", err)
}
if resolved != proto1.ID {
t.Errorf("Expected %s, got %s", proto1.ID, resolved)
}
})
t.Run("resolve by title case-insensitive", func(t *testing.T) {
resolved, err := resolveProtoIDOrTitle(ctx, s, "MOL-POLECAT-WORK")
if err != nil {
t.Fatalf("Failed to resolve by title (case-insensitive): %v", err)
}
if resolved != proto1.ID {
t.Errorf("Expected %s, got %s", proto1.ID, resolved)
}
})
t.Run("resolve by unique partial title", func(t *testing.T) {
resolved, err := resolveProtoIDOrTitle(ctx, s, "polecat")
if err != nil {
t.Fatalf("Failed to resolve by partial title: %v", err)
}
if resolved != proto1.ID {
t.Errorf("Expected %s, got %s", proto1.ID, resolved)
}
})
t.Run("ambiguous partial title returns error", func(t *testing.T) {
// "mol-" matches all three protos
_, err := resolveProtoIDOrTitle(ctx, s, "mol-")
if err == nil {
t.Fatal("Expected error for ambiguous title, got nil")
}
if !strings.Contains(err.Error(), "ambiguous") {
t.Errorf("Expected 'ambiguous' in error, got: %v", err)
}
})
t.Run("non-existent returns error", func(t *testing.T) {
_, err := resolveProtoIDOrTitle(ctx, s, "nonexistent-proto")
if err == nil {
t.Fatal("Expected error for non-existent proto, got nil")
}
if !strings.Contains(err.Error(), "no proto found") {
t.Errorf("Expected 'no proto found' in error, got: %v", err)
}
})
t.Run("non-proto ID returns error", func(t *testing.T) {
// This ID exists but is not a proto (no template label)
_, err := resolveProtoIDOrTitle(ctx, s, nonProto.ID)
if err == nil {
t.Fatal("Expected error for non-proto ID, got nil")
}
})
}
// TestLoadTemplateSubgraphWithManyChildren tests loading with 4+ children (bd-c8d5)
// This reproduces the bug where only 2 of 4 children were loaded.
func TestLoadTemplateSubgraphWithManyChildren(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "bd-test-many-children-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
testDB := filepath.Join(tmpDir, "test.db")
s := newTestStore(t, testDB)
defer s.Close()
ctx := context.Background()
h := &templateTestHelper{s: s, ctx: ctx, t: t}
t.Run("load epic with 4 children", func(t *testing.T) {
epic := h.createIssue("Proto Workflow", "Workflow with 4 steps", types.TypeEpic, 1)
h.addLabel(epic.ID, BeadsTemplateLabel)
// Create 4 children with different titles (like the bug report)
child1 := h.createIssue("load-context", "", types.TypeTask, 2)
child2 := h.createIssue("implement", "", types.TypeTask, 2)
child3 := h.createIssue("self-review", "", types.TypeTask, 2)
child4 := h.createIssue("request-shutdown", "", types.TypeTask, 2)
h.addParentChild(child1.ID, epic.ID)
h.addParentChild(child2.ID, epic.ID)
h.addParentChild(child3.ID, epic.ID)
h.addParentChild(child4.ID, epic.ID)
subgraph, err := loadTemplateSubgraph(ctx, s, epic.ID)
if err != nil {
t.Fatalf("loadTemplateSubgraph failed: %v", err)
}
// Should have 5 issues: 1 root + 4 children
if len(subgraph.Issues) != 5 {
t.Errorf("Issues count = %d, want 5 (epic + 4 children)", len(subgraph.Issues))
t.Logf("Found issues:")
for _, iss := range subgraph.Issues {
t.Logf(" - %s: %s", iss.ID, iss.Title)
}
}
// Verify each child is in the subgraph
childIDs := []string{child1.ID, child2.ID, child3.ID, child4.ID}
for _, childID := range childIDs {
if _, ok := subgraph.IssueMap[childID]; !ok {
t.Errorf("Child %s not found in subgraph", childID)
}
}
})
t.Run("clone epic with 4 children creates all 4", func(t *testing.T) {
epic := h.createIssue("Polecat Work", "", types.TypeEpic, 1)
h.addLabel(epic.ID, BeadsTemplateLabel)
child1 := h.createIssue("load-context", "", types.TypeTask, 2)
child2 := h.createIssue("implement", "", types.TypeTask, 2)
child3 := h.createIssue("self-review", "", types.TypeTask, 2)
child4 := h.createIssue("request-shutdown", "", types.TypeTask, 2)
h.addParentChild(child1.ID, epic.ID)
h.addParentChild(child2.ID, epic.ID)
h.addParentChild(child3.ID, epic.ID)
h.addParentChild(child4.ID, epic.ID)
subgraph, err := loadTemplateSubgraph(ctx, s, epic.ID)
if err != nil {
t.Fatalf("loadTemplateSubgraph failed: %v", err)
}
opts := CloneOptions{Actor: "test-user"}
result, err := cloneSubgraph(ctx, s, subgraph, opts)
if err != nil {
t.Fatalf("cloneSubgraph failed: %v", err)
}
// Should create 5 issues (1 root + 4 children)
if result.Created != 5 {
t.Errorf("Created = %d, want 5", result.Created)
}
// Verify all children were mapped
for _, childID := range []string{child1.ID, child2.ID, child3.ID, child4.ID} {
if _, ok := result.IDMapping[childID]; !ok {
t.Errorf("Child %s not in ID mapping", childID)
}
}
})
t.Run("load epic with hierarchical child IDs - bd-c8d5 reproduction", func(t *testing.T) {
// This replicates the exact scenario from bd-c8d5:
// Proto gt-lwuu has children gt-lwuu.1, gt-lwuu.2, gt-lwuu.3, gt-lwuu.8
// Only gt-lwuu.1 and gt-lwuu.2 were being loaded
// Using test-xxx prefix to match test database configuration
epic := h.createIssueWithID("test-lwuu", "mol-polecat-work", "", types.TypeEpic, 1)
h.addLabel(epic.ID, BeadsTemplateLabel)
// Create children with hierarchical IDs (note the gap: .1, .2, .3, .8)
child1 := h.createIssueWithID("test-lwuu.1", "load-context", "", types.TypeTask, 2)
child2 := h.createIssueWithID("test-lwuu.2", "implement", "", types.TypeTask, 2)
child3 := h.createIssueWithID("test-lwuu.3", "self-review", "", types.TypeTask, 2)
child8 := h.createIssueWithID("test-lwuu.8", "request-shutdown", "", types.TypeTask, 2)
h.addParentChild(child1.ID, epic.ID)
h.addParentChild(child2.ID, epic.ID)
h.addParentChild(child3.ID, epic.ID)
h.addParentChild(child8.ID, epic.ID)
subgraph, err := loadTemplateSubgraph(ctx, s, epic.ID)
if err != nil {
t.Fatalf("loadTemplateSubgraph failed: %v", err)
}
// Should have 5 issues: 1 root + 4 children
if len(subgraph.Issues) != 5 {
t.Errorf("Issues count = %d, want 5", len(subgraph.Issues))
t.Logf("Found issues:")
for _, iss := range subgraph.Issues {
t.Logf(" - %s: %s", iss.ID, iss.Title)
}
}
// Verify all 4 children are loaded
expectedChildren := []string{"test-lwuu.1", "test-lwuu.2", "test-lwuu.3", "test-lwuu.8"}
for _, childID := range expectedChildren {
if _, ok := subgraph.IssueMap[childID]; !ok {
t.Errorf("Child %s not found in subgraph", childID)
}
}
})
t.Run("children with wrong dep type are not loaded - potential bug cause", func(t *testing.T) {
// This tests the hypothesis that the bug is caused by children
// having the wrong dependency type (e.g., "blocks" instead of "parent-child")
epic := h.createIssue("Proto with mixed deps", "", types.TypeEpic, 1)
h.addLabel(epic.ID, BeadsTemplateLabel)
child1 := h.createIssue("load-context", "", types.TypeTask, 2)
child2 := h.createIssue("implement", "", types.TypeTask, 2)
child3 := h.createIssue("self-review", "", types.TypeTask, 2)
child4 := h.createIssue("request-shutdown", "", types.TypeTask, 2)
// Only child1 and child2 have parent-child dependency
h.addParentChild(child1.ID, epic.ID)
h.addParentChild(child2.ID, epic.ID)
// child3 and child4 have "blocks" dependency (wrong type)
blocksDep := &types.Dependency{
IssueID: child3.ID,
DependsOnID: epic.ID,
Type: types.DepBlocks,
}
if err := s.AddDependency(ctx, blocksDep, "test-user"); err != nil {
t.Fatalf("Failed to add blocks dependency: %v", err)
}
blocksDep2 := &types.Dependency{
IssueID: child4.ID,
DependsOnID: epic.ID,
Type: types.DepBlocks,
}
if err := s.AddDependency(ctx, blocksDep2, "test-user"); err != nil {
t.Fatalf("Failed to add blocks dependency: %v", err)
}
subgraph, err := loadTemplateSubgraph(ctx, s, epic.ID)
if err != nil {
t.Fatalf("loadTemplateSubgraph failed: %v", err)
}
// With non-hierarchical IDs, only parent-child deps are loaded
// This is expected - the hierarchical ID fallback doesn't apply
t.Logf("Found %d issues (expecting 3 without hierarchical IDs):", len(subgraph.Issues))
for _, iss := range subgraph.Issues {
t.Logf(" - %s: %s", iss.ID, iss.Title)
}
if len(subgraph.Issues) != 3 {
t.Errorf("Expected 3 issues (without hierarchical ID fallback), got %d", len(subgraph.Issues))
}
})
t.Run("hierarchical children with wrong dep type ARE loaded - bd-c8d5 fix", func(t *testing.T) {
// This tests the fix for bd-c8d5:
// Hierarchical children (parent.N pattern) are loaded even if they have
// wrong dependency types, using the ID pattern fallback.
epic := h.createIssueWithID("test-pcat", "Proto with mixed deps", "", types.TypeEpic, 1)
h.addLabel(epic.ID, BeadsTemplateLabel)
// child1 and child2 have correct parent-child dependency
child1 := h.createIssueWithID("test-pcat.1", "load-context", "", types.TypeTask, 2)
child2 := h.createIssueWithID("test-pcat.2", "implement", "", types.TypeTask, 2)
h.addParentChild(child1.ID, epic.ID)
h.addParentChild(child2.ID, epic.ID)
// child3 has NO dependency at all (broken data)
_ = h.createIssueWithID("test-pcat.3", "self-review", "", types.TypeTask, 2)
// No dependency added for child3!
// child8 has wrong dependency type (blocks instead of parent-child)
child8 := h.createIssueWithID("test-pcat.8", "request-shutdown", "", types.TypeTask, 2)
blocksDep := &types.Dependency{
IssueID: child8.ID,
DependsOnID: epic.ID,
Type: types.DepBlocks,
}
if err := s.AddDependency(ctx, blocksDep, "test-user"); err != nil {
t.Fatalf("Failed to add blocks dependency: %v", err)
}
subgraph, err := loadTemplateSubgraph(ctx, s, epic.ID)
if err != nil {
t.Fatalf("loadTemplateSubgraph failed: %v", err)
}
t.Logf("Found %d issues:", len(subgraph.Issues))
for _, iss := range subgraph.Issues {
t.Logf(" - %s: %s", iss.ID, iss.Title)
}
// With the bd-c8d5 fix, all 5 issues should be loaded:
// 1 root + 4 hierarchical children (found by ID pattern fallback)
if len(subgraph.Issues) != 5 {
t.Errorf("Expected 5 issues (root + 4 hierarchical children), got %d", len(subgraph.Issues))
}
// Verify all children are in the subgraph
expectedChildren := []string{"test-pcat.1", "test-pcat.2", "test-pcat.3", "test-pcat.8"}
for _, childID := range expectedChildren {
if _, ok := subgraph.IssueMap[childID]; !ok {
t.Errorf("Child %s not found in subgraph", childID)
}
}
})
}