diff --git a/cmd/bd/mol_ready_gated.go b/cmd/bd/mol_ready_gated.go new file mode 100644 index 00000000..deae7468 --- /dev/null +++ b/cmd/bd/mol_ready_gated.go @@ -0,0 +1,225 @@ +package main + +import ( + "context" + "fmt" + "os" + "sort" + + "github.com/spf13/cobra" + "github.com/steveyegge/beads/internal/storage" + "github.com/steveyegge/beads/internal/types" + "github.com/steveyegge/beads/internal/ui" +) + +// GatedMolecule represents a molecule ready for gate-resume dispatch +type GatedMolecule struct { + MoleculeID string `json:"molecule_id"` + MoleculeTitle string `json:"molecule_title"` + ClosedGate *types.Issue `json:"closed_gate"` + ReadyStep *types.Issue `json:"ready_step"` +} + +// GatedReadyOutput is the JSON output for bd mol ready --gated +type GatedReadyOutput struct { + Molecules []*GatedMolecule `json:"molecules"` + Count int `json:"count"` +} + +var molReadyGatedCmd = &cobra.Command{ + Use: "ready --gated", + Short: "Find molecules ready for gate-resume dispatch", + Long: `Find molecules where a gate has closed and the workflow is ready to resume. + +This command discovers molecules waiting at a gate step where: +1. The molecule has a gate bead that blocks a step +2. The gate bead is now closed (condition satisfied) +3. The blocked step is now ready to proceed +4. No agent currently has this molecule hooked + +This enables discovery-based resume without explicit waiter tracking. +The Deacon patrol uses this to find and dispatch gate-ready molecules. + +Examples: + bd mol ready --gated # Find all gate-ready molecules + bd mol ready --gated --json # JSON output for automation`, + Run: runMolReadyGated, +} + +func runMolReadyGated(cmd *cobra.Command, args []string) { + ctx := rootCtx + + // mol ready --gated requires direct store access + if store == nil { + if daemonClient != nil { + fmt.Fprintf(os.Stderr, "Error: mol ready --gated requires direct database access\n") + fmt.Fprintf(os.Stderr, "Hint: use --no-daemon flag: bd --no-daemon mol ready --gated\n") + } else { + fmt.Fprintf(os.Stderr, "Error: no database connection\n") + } + os.Exit(1) + } + + // Find gate-ready molecules + molecules, err := findGateReadyMolecules(ctx, store) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + if jsonOutput { + output := GatedReadyOutput{ + Molecules: molecules, + Count: len(molecules), + } + if output.Molecules == nil { + output.Molecules = []*GatedMolecule{} + } + outputJSON(output) + return + } + + // Human-readable output + if len(molecules) == 0 { + fmt.Printf("\n%s No molecules ready for gate-resume dispatch\n\n", ui.RenderWarn("")) + return + } + + fmt.Printf("\n%s Molecules ready for gate-resume dispatch (%d):\n\n", + ui.RenderAccent(""), len(molecules)) + + for i, mol := range molecules { + fmt.Printf("%d. %s: %s\n", i+1, ui.RenderID(mol.MoleculeID), mol.MoleculeTitle) + if mol.ClosedGate != nil { + fmt.Printf(" Gate closed: %s (%s)\n", mol.ClosedGate.ID, mol.ClosedGate.AwaitType) + } + if mol.ReadyStep != nil { + fmt.Printf(" Ready step: %s - %s\n", mol.ReadyStep.ID, mol.ReadyStep.Title) + } + fmt.Println() + } + + fmt.Println("To dispatch a molecule:") + fmt.Println(" gt sling --mol ") +} + +// findGateReadyMolecules finds molecules where a gate has closed and work can resume. +// +// Logic: +// 1. Find all closed gate beads +// 2. For each closed gate, find what step it was blocking +// 3. Check if that step is now ready (unblocked) +// 4. Find the parent molecule +// 5. Filter out molecules that are already hooked by someone +func findGateReadyMolecules(ctx context.Context, s storage.Storage) ([]*GatedMolecule, error) { + // Step 1: Find all closed gate beads + gateType := types.TypeGate + closedStatus := types.StatusClosed + gateFilter := types.IssueFilter{ + IssueType: &gateType, + Status: &closedStatus, + Limit: 100, + } + + closedGates, err := s.SearchIssues(ctx, "", gateFilter) + if err != nil { + return nil, fmt.Errorf("searching closed gates: %w", err) + } + + if len(closedGates) == 0 { + return nil, nil + } + + // Step 2: Get ready work to check which steps are ready + readyIssues, err := s.GetReadyWork(ctx, types.WorkFilter{Limit: 500}) + if err != nil { + return nil, fmt.Errorf("getting ready work: %w", err) + } + readyIDs := make(map[string]bool) + for _, issue := range readyIssues { + readyIDs[issue.ID] = true + } + + // Step 3: Get hooked molecules to filter out + hookedStatus := types.StatusHooked + hookedFilter := types.IssueFilter{ + Status: &hookedStatus, + Limit: 100, + } + hookedIssues, err := s.SearchIssues(ctx, "", hookedFilter) + if err != nil { + // Non-fatal: just continue without filtering + hookedIssues = nil + } + hookedMolecules := make(map[string]bool) + for _, issue := range hookedIssues { + // If the hooked issue is a molecule root, mark it + hookedMolecules[issue.ID] = true + // Also find parent molecule for hooked steps + if parentMol := findParentMolecule(ctx, s, issue.ID); parentMol != "" { + hookedMolecules[parentMol] = true + } + } + + // Step 4: For each closed gate, find issues that depend on it (were blocked) + moleculeMap := make(map[string]*GatedMolecule) + + for _, gate := range closedGates { + // Find issues that depend on this gate (GetDependents returns issues where depends_on_id = gate.ID) + dependents, err := s.GetDependents(ctx, gate.ID) + if err != nil { + continue + } + + for _, dependent := range dependents { + // Check if the previously blocked step is now ready + if !readyIDs[dependent.ID] { + continue + } + + // Find the parent molecule + moleculeID := findParentMolecule(ctx, s, dependent.ID) + if moleculeID == "" { + continue + } + + // Skip if already hooked + if hookedMolecules[moleculeID] { + continue + } + + // Get molecule details + moleculeIssue, err := s.GetIssue(ctx, moleculeID) + if err != nil || moleculeIssue == nil { + continue + } + + // Add to results (dedupe by molecule ID) + if _, exists := moleculeMap[moleculeID]; !exists { + moleculeMap[moleculeID] = &GatedMolecule{ + MoleculeID: moleculeID, + MoleculeTitle: moleculeIssue.Title, + ClosedGate: gate, + ReadyStep: dependent, + } + } + } + } + + // Convert to slice and sort + var molecules []*GatedMolecule + for _, mol := range moleculeMap { + molecules = append(molecules, mol) + } + sort.Slice(molecules, func(i, j int) bool { + return molecules[i].MoleculeID < molecules[j].MoleculeID + }) + + return molecules, nil +} + +func init() { + // Note: --gated flag is registered in ready.go + // Also add as a subcommand under mol for discoverability + molCmd.AddCommand(molReadyGatedCmd) +} diff --git a/cmd/bd/mol_ready_gated_test.go b/cmd/bd/mol_ready_gated_test.go new file mode 100644 index 00000000..9e74412e --- /dev/null +++ b/cmd/bd/mol_ready_gated_test.go @@ -0,0 +1,439 @@ +package main + +import ( + "context" + "fmt" + "os" + "path/filepath" + "testing" + "time" + + "github.com/steveyegge/beads/internal/storage/sqlite" + "github.com/steveyegge/beads/internal/types" +) + +// setupGatedTestDB creates a temporary file-based test database +func setupGatedTestDB(t *testing.T) (*sqlite.SQLiteStorage, func()) { + t.Helper() + tmpDir, err := os.MkdirTemp("", "bd-test-gated-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + + testDB := filepath.Join(tmpDir, "test.db") + store, err := sqlite.New(context.Background(), testDB) + if err != nil { + os.RemoveAll(tmpDir) + t.Fatalf("Failed to create test database: %v", err) + } + + // Set issue_prefix (required for beads) + ctx := context.Background() + if err := store.SetConfig(ctx, "issue_prefix", "test"); err != nil { + store.Close() + os.RemoveAll(tmpDir) + t.Fatalf("Failed to set issue_prefix: %v", err) + } + + cleanup := func() { + store.Close() + os.RemoveAll(tmpDir) + } + + return store, cleanup +} + +// ============================================================================= +// mol ready --gated Tests (bd-lhalq: Gate-resume discovery) +// ============================================================================= + +// TestFindGateReadyMolecules_NoGates tests finding gate-ready molecules when no gates exist +func TestFindGateReadyMolecules_NoGates(t *testing.T) { + ctx := context.Background() + store, cleanup := setupGatedTestDB(t) + defer cleanup() + + // Create a regular molecule (no gates) + mol := &types.Issue{ + ID: "test-mol-001", + Title: "Test Molecule", + IssueType: types.TypeEpic, + Status: types.StatusInProgress, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + step := &types.Issue{ + ID: "test-mol-001.step1", + Title: "Step 1", + IssueType: types.TypeTask, + Status: types.StatusOpen, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + if err := store.CreateIssue(ctx, mol, "test"); err != nil { + t.Fatalf("Failed to create molecule: %v", err) + } + if err := store.CreateIssue(ctx, step, "test"); err != nil { + t.Fatalf("Failed to create step: %v", err) + } + + // Add parent-child relationship + if err := store.AddDependency(ctx, &types.Dependency{ + IssueID: step.ID, + DependsOnID: mol.ID, + Type: types.DepParentChild, + }, "test"); err != nil { + t.Fatalf("Failed to add dependency: %v", err) + } + + // Find gate-ready molecules + molecules, err := findGateReadyMolecules(ctx, store) + if err != nil { + t.Fatalf("findGateReadyMolecules failed: %v", err) + } + + if len(molecules) != 0 { + t.Errorf("Expected 0 gate-ready molecules, got %d", len(molecules)) + } +} + +// TestFindGateReadyMolecules_ClosedGate tests finding molecules with closed gates +func TestFindGateReadyMolecules_ClosedGate(t *testing.T) { + ctx := context.Background() + store, cleanup := setupGatedTestDB(t) + defer cleanup() + + // Create molecule structure: + // mol-001 + // └── gate-await-ci (closed) + // └── step1 (blocked by gate-await-ci, should become ready) + + mol := &types.Issue{ + ID: "test-mol-002", + Title: "Test Molecule with Gate", + IssueType: types.TypeEpic, + Status: types.StatusInProgress, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + gate := &types.Issue{ + ID: "test-mol-002.gate-await-ci", + Title: "Gate: gh:run ci-workflow", + IssueType: types.TypeGate, + Status: types.StatusClosed, // Gate has closed + AwaitType: "gh:run", + AwaitID: "ci-workflow", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + step := &types.Issue{ + ID: "test-mol-002.step1", + Title: "Deploy after CI", + IssueType: types.TypeTask, + Status: types.StatusOpen, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + if err := store.CreateIssue(ctx, mol, "test"); err != nil { + t.Fatalf("Failed to create molecule: %v", err) + } + if err := store.CreateIssue(ctx, gate, "test"); err != nil { + t.Fatalf("Failed to create gate: %v", err) + } + if err := store.CreateIssue(ctx, step, "test"); err != nil { + t.Fatalf("Failed to create step: %v", err) + } + + // Add parent-child relationships + if err := store.AddDependency(ctx, &types.Dependency{ + IssueID: gate.ID, + DependsOnID: mol.ID, + Type: types.DepParentChild, + }, "test"); err != nil { + t.Fatalf("Failed to add gate parent-child: %v", err) + } + if err := store.AddDependency(ctx, &types.Dependency{ + IssueID: step.ID, + DependsOnID: mol.ID, + Type: types.DepParentChild, + }, "test"); err != nil { + t.Fatalf("Failed to add step parent-child: %v", err) + } + + // Add blocking dependency: step depends on gate (gate blocks step) + if err := store.AddDependency(ctx, &types.Dependency{ + IssueID: step.ID, + DependsOnID: gate.ID, + Type: types.DepBlocks, + }, "test"); err != nil { + t.Fatalf("Failed to add blocking dependency: %v", err) + } + + // Find gate-ready molecules + molecules, err := findGateReadyMolecules(ctx, store) + if err != nil { + t.Fatalf("findGateReadyMolecules failed: %v", err) + } + + if len(molecules) != 1 { + t.Errorf("Expected 1 gate-ready molecule, got %d", len(molecules)) + return + } + + if molecules[0].MoleculeID != mol.ID { + t.Errorf("Expected molecule ID %s, got %s", mol.ID, molecules[0].MoleculeID) + } + if molecules[0].ClosedGate == nil { + t.Error("Expected closed gate to be set") + } else if molecules[0].ClosedGate.ID != gate.ID { + t.Errorf("Expected closed gate ID %s, got %s", gate.ID, molecules[0].ClosedGate.ID) + } + if molecules[0].ReadyStep == nil { + t.Error("Expected ready step to be set") + } else if molecules[0].ReadyStep.ID != step.ID { + t.Errorf("Expected ready step ID %s, got %s", step.ID, molecules[0].ReadyStep.ID) + } +} + +// TestFindGateReadyMolecules_OpenGate tests that open gates don't trigger ready +func TestFindGateReadyMolecules_OpenGate(t *testing.T) { + ctx := context.Background() + store, cleanup := setupGatedTestDB(t) + defer cleanup() + + // Create molecule with OPEN gate + mol := &types.Issue{ + ID: "test-mol-003", + Title: "Test Molecule with Open Gate", + IssueType: types.TypeEpic, + Status: types.StatusInProgress, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + gate := &types.Issue{ + ID: "test-mol-003.gate-await-ci", + Title: "Gate: gh:run ci-workflow", + IssueType: types.TypeGate, + Status: types.StatusOpen, // Gate is still open + AwaitType: "gh:run", + AwaitID: "ci-workflow", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + step := &types.Issue{ + ID: "test-mol-003.step1", + Title: "Deploy after CI", + IssueType: types.TypeTask, + Status: types.StatusOpen, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + if err := store.CreateIssue(ctx, mol, "test"); err != nil { + t.Fatalf("Failed to create molecule: %v", err) + } + if err := store.CreateIssue(ctx, gate, "test"); err != nil { + t.Fatalf("Failed to create gate: %v", err) + } + if err := store.CreateIssue(ctx, step, "test"); err != nil { + t.Fatalf("Failed to create step: %v", err) + } + + // Add parent-child relationships + if err := store.AddDependency(ctx, &types.Dependency{ + IssueID: gate.ID, + DependsOnID: mol.ID, + Type: types.DepParentChild, + }, "test"); err != nil { + t.Fatalf("Failed to add gate parent-child: %v", err) + } + if err := store.AddDependency(ctx, &types.Dependency{ + IssueID: step.ID, + DependsOnID: mol.ID, + Type: types.DepParentChild, + }, "test"); err != nil { + t.Fatalf("Failed to add step parent-child: %v", err) + } + + // Add blocking dependency: step depends on gate + if err := store.AddDependency(ctx, &types.Dependency{ + IssueID: step.ID, + DependsOnID: gate.ID, + Type: types.DepBlocks, + }, "test"); err != nil { + t.Fatalf("Failed to add blocking dependency: %v", err) + } + + // Find gate-ready molecules + molecules, err := findGateReadyMolecules(ctx, store) + if err != nil { + t.Fatalf("findGateReadyMolecules failed: %v", err) + } + + if len(molecules) != 0 { + t.Errorf("Expected 0 gate-ready molecules (gate is open), got %d", len(molecules)) + } +} + +// TestFindGateReadyMolecules_HookedMolecule tests that hooked molecules are filtered out +func TestFindGateReadyMolecules_HookedMolecule(t *testing.T) { + ctx := context.Background() + store, cleanup := setupGatedTestDB(t) + defer cleanup() + + // Create molecule with closed gate, but molecule is hooked + mol := &types.Issue{ + ID: "test-mol-004", + Title: "Test Hooked Molecule", + IssueType: types.TypeEpic, + Status: types.StatusHooked, // Already hooked by an agent + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + gate := &types.Issue{ + ID: "test-mol-004.gate-await-ci", + Title: "Gate: gh:run ci-workflow", + IssueType: types.TypeGate, + Status: types.StatusClosed, + AwaitType: "gh:run", + AwaitID: "ci-workflow", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + step := &types.Issue{ + ID: "test-mol-004.step1", + Title: "Deploy after CI", + IssueType: types.TypeTask, + Status: types.StatusOpen, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + if err := store.CreateIssue(ctx, mol, "test"); err != nil { + t.Fatalf("Failed to create molecule: %v", err) + } + if err := store.CreateIssue(ctx, gate, "test"); err != nil { + t.Fatalf("Failed to create gate: %v", err) + } + if err := store.CreateIssue(ctx, step, "test"); err != nil { + t.Fatalf("Failed to create step: %v", err) + } + + // Add parent-child relationships + if err := store.AddDependency(ctx, &types.Dependency{ + IssueID: gate.ID, + DependsOnID: mol.ID, + Type: types.DepParentChild, + }, "test"); err != nil { + t.Fatalf("Failed to add gate parent-child: %v", err) + } + if err := store.AddDependency(ctx, &types.Dependency{ + IssueID: step.ID, + DependsOnID: mol.ID, + Type: types.DepParentChild, + }, "test"); err != nil { + t.Fatalf("Failed to add step parent-child: %v", err) + } + + // Add blocking dependency + if err := store.AddDependency(ctx, &types.Dependency{ + IssueID: step.ID, + DependsOnID: gate.ID, + Type: types.DepBlocks, + }, "test"); err != nil { + t.Fatalf("Failed to add blocking dependency: %v", err) + } + + // Find gate-ready molecules + molecules, err := findGateReadyMolecules(ctx, store) + if err != nil { + t.Fatalf("findGateReadyMolecules failed: %v", err) + } + + if len(molecules) != 0 { + t.Errorf("Expected 0 gate-ready molecules (molecule is hooked), got %d", len(molecules)) + } +} + +// TestFindGateReadyMolecules_MultipleGates tests handling multiple closed gates +func TestFindGateReadyMolecules_MultipleGates(t *testing.T) { + ctx := context.Background() + store, cleanup := setupGatedTestDB(t) + defer cleanup() + + // Create two molecules, each with a closed gate + for i := 1; i <= 2; i++ { + molID := fmt.Sprintf("test-multi-%d", i) + mol := &types.Issue{ + ID: molID, + Title: fmt.Sprintf("Multi Gate Mol %d", i), + IssueType: types.TypeEpic, + Status: types.StatusInProgress, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + gate := &types.Issue{ + ID: fmt.Sprintf("%s.gate", molID), + Title: "Gate: gh:run", + IssueType: types.TypeGate, + Status: types.StatusClosed, + AwaitType: "gh:run", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + step := &types.Issue{ + ID: fmt.Sprintf("%s.step1", molID), + Title: "Step 1", + IssueType: types.TypeTask, + Status: types.StatusOpen, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + if err := store.CreateIssue(ctx, mol, "test"); err != nil { + t.Fatalf("Failed to create molecule %d: %v", i, err) + } + if err := store.CreateIssue(ctx, gate, "test"); err != nil { + t.Fatalf("Failed to create gate %d: %v", i, err) + } + if err := store.CreateIssue(ctx, step, "test"); err != nil { + t.Fatalf("Failed to create step %d: %v", i, err) + } + + // Add dependencies + if err := store.AddDependency(ctx, &types.Dependency{ + IssueID: gate.ID, + DependsOnID: mol.ID, + Type: types.DepParentChild, + }, "test"); err != nil { + t.Fatalf("Failed to add gate parent-child %d: %v", i, err) + } + if err := store.AddDependency(ctx, &types.Dependency{ + IssueID: step.ID, + DependsOnID: mol.ID, + Type: types.DepParentChild, + }, "test"); err != nil { + t.Fatalf("Failed to add step parent-child %d: %v", i, err) + } + if err := store.AddDependency(ctx, &types.Dependency{ + IssueID: step.ID, + DependsOnID: gate.ID, + Type: types.DepBlocks, + }, "test"); err != nil { + t.Fatalf("Failed to add blocking dep %d: %v", i, err) + } + } + + // Find gate-ready molecules + molecules, err := findGateReadyMolecules(ctx, store) + if err != nil { + t.Fatalf("findGateReadyMolecules failed: %v", err) + } + + if len(molecules) != 2 { + t.Errorf("Expected 2 gate-ready molecules, got %d", len(molecules)) + } +} + diff --git a/cmd/bd/ready.go b/cmd/bd/ready.go index 800fc840..e3c32f55 100644 --- a/cmd/bd/ready.go +++ b/cmd/bd/ready.go @@ -23,8 +23,18 @@ var readyCmd = &cobra.Command{ Use --mol to filter to a specific molecule's steps: bd ready --mol bd-patrol # Show ready steps within molecule +Use --gated to find molecules ready for gate-resume dispatch: + bd ready --gated # Find molecules where a gate closed + This is useful for agents executing molecules to see which steps can run next.`, Run: func(cmd *cobra.Command, args []string) { + // Handle --gated flag (gate-resume discovery) + gated, _ := cmd.Flags().GetBool("gated") + if gated { + runMolReadyGated(cmd, args) + return + } + // Handle molecule-specific ready query molID, _ := cmd.Flags().GetString("mol") if molID != "" { @@ -451,6 +461,7 @@ func init() { readyCmd.Flags().String("mol-type", "", "Filter by molecule type: swarm, patrol, or work") readyCmd.Flags().Bool("pretty", false, "Display issues in a tree format with status/priority symbols") readyCmd.Flags().Bool("include-deferred", false, "Include issues with future defer_until timestamps") + readyCmd.Flags().Bool("gated", false, "Find molecules ready for gate-resume dispatch") rootCmd.AddCommand(readyCmd) blockedCmd.Flags().String("parent", "", "Filter to descendants of this bead/epic") rootCmd.AddCommand(blockedCmd)