Files
beads/cmd/bd/mol_ready_gated_test.go
dave 6331a9771a 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
2026-01-08 21:22:17 -08:00

440 lines
12 KiB
Go

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))
}
}