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/types"
"github.com/steveyegge/beads/internal/ui"
"github.com/steveyegge/beads/internal/utils"
)
var molRunCmd = &cobra.Command{
Use: "run <proto-id>",
Use: "run <proto-id-or-title>",
Short: "Spawn proto and start execution (spawn + assign + pin)",
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
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
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.
Example:
bd mol run mol-version-bump --var version=1.2.0
bd mol run bd-qqc --var version=0.32.0 --var date=2025-01-01
bd mol run mol-polecat-work --var issue=gt-xxx # By title
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`,
Args: cobra.ExactArgs(1),
Run: runMolRun,
@@ -97,10 +100,10 @@ func runMolRun(cmd *cobra.Command, args []string) {
defer func() { _ = templateStore.Close() }()
}
// Resolve molecule ID from template store
moleculeID, err := utils.ResolvePartialID(ctx, templateStore, args[0])
// Resolve molecule ID from template store (supports both ID and title - bd-drcx)
moleculeID, err := resolveProtoIDOrTitle(ctx, templateStore, args[0])
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)
}

View File

@@ -409,8 +409,14 @@ func loadTemplateSubgraph(ctx context.Context, s storage.Storage, templateID str
}
// 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 {
// 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)
if err != nil {
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
subgraph.Issues = append(subgraph.Issues, dependent)
subgraph.IssueMap[dependent.ID] = dependent
addedChildren[dependent.ID] = true
// Recurse to get children of this child
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
}
// 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
// =============================================================================

View File

@@ -4,6 +4,7 @@ import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"github.com/steveyegge/beads/internal/storage/sqlite"
@@ -474,3 +475,356 @@ func TestExtractAllVariables(t *testing.T) {
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)
}
}
})
}