feat(mol): add bd ready --gated for gate-resume discovery (bd-lhalq)
Add new command to find molecules where a gate has closed and the workflow is ready to resume: - `bd ready --gated` - Flag on existing ready command - `bd mol ready` - Subcommand for discoverability The command finds molecules where: 1. A gate bead (type=gate) has been closed 2. The step blocked by that gate is now ready 3. The molecule is not currently hooked by any agent This enables the Deacon patrol to discover and dispatch gate-ready molecules without explicit waiter tracking, supporting async molecule resume workflows. Includes 5 tests verifying: - No false positives when no gates exist - Detection of molecules with closed gates - Filtering out molecules with open gates - Filtering out already-hooked molecules - Handling multiple gate-ready molecules Part of epic bd-ka761 (Gate-based async molecule resume). Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> Executed-By: beads/crew/dave Rig: beads Role: crew
This commit is contained in:
225
cmd/bd/mol_ready_gated.go
Normal file
225
cmd/bd/mol_ready_gated.go
Normal file
@@ -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 <agent> --mol <molecule-id>")
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
439
cmd/bd/mol_ready_gated_test.go
Normal file
439
cmd/bd/mol_ready_gated_test.go
Normal file
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user