Files
beads/cmd/bd/ready.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

469 lines
15 KiB
Go

package main
import (
"encoding/json"
"fmt"
"os"
"github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/config"
"github.com/steveyegge/beads/internal/rpc"
"github.com/steveyegge/beads/internal/storage/sqlite"
"github.com/steveyegge/beads/internal/types"
"github.com/steveyegge/beads/internal/ui"
"github.com/steveyegge/beads/internal/util"
"github.com/steveyegge/beads/internal/utils"
)
var readyCmd = &cobra.Command{
Use: "ready",
Short: "Show ready work (no blockers, open or in_progress)",
Long: `Show ready work (issues with no blockers that are open or in_progress).
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 != "" {
runMoleculeReady(cmd, molID)
return
}
limit, _ := cmd.Flags().GetInt("limit")
assignee, _ := cmd.Flags().GetString("assignee")
unassigned, _ := cmd.Flags().GetBool("unassigned")
sortPolicy, _ := cmd.Flags().GetString("sort")
labels, _ := cmd.Flags().GetStringSlice("label")
labelsAny, _ := cmd.Flags().GetStringSlice("label-any")
issueType, _ := cmd.Flags().GetString("type")
issueType = util.NormalizeIssueType(issueType) // Expand aliases (mr→merge-request, etc.)
parentID, _ := cmd.Flags().GetString("parent")
molTypeStr, _ := cmd.Flags().GetString("mol-type")
prettyFormat, _ := cmd.Flags().GetBool("pretty")
includeDeferred, _ := cmd.Flags().GetBool("include-deferred")
var molType *types.MolType
if molTypeStr != "" {
mt := types.MolType(molTypeStr)
if !mt.IsValid() {
fmt.Fprintf(os.Stderr, "Error: invalid mol-type %q (must be swarm, patrol, or work)\n", molTypeStr)
os.Exit(1)
}
molType = &mt
}
// Use global jsonOutput set by PersistentPreRun (respects config.yaml + env vars)
// Normalize labels: trim, dedupe, remove empty
labels = util.NormalizeLabels(labels)
labelsAny = util.NormalizeLabels(labelsAny)
// Apply directory-aware label scoping if no labels explicitly provided (GH#541)
if len(labels) == 0 && len(labelsAny) == 0 {
if dirLabels := config.GetDirectoryLabels(); len(dirLabels) > 0 {
labelsAny = dirLabels
}
}
filter := types.WorkFilter{
// Leave Status empty to get both 'open' and 'in_progress'
Type: issueType,
Limit: limit,
Unassigned: unassigned,
SortPolicy: types.SortPolicy(sortPolicy),
Labels: labels,
LabelsAny: labelsAny,
IncludeDeferred: includeDeferred, // GH#820: respect --include-deferred flag
}
// Use Changed() to properly handle P0 (priority=0)
if cmd.Flags().Changed("priority") {
priority, _ := cmd.Flags().GetInt("priority")
filter.Priority = &priority
}
if assignee != "" && !unassigned {
filter.Assignee = &assignee
}
if parentID != "" {
filter.ParentID = &parentID
}
if molType != nil {
filter.MolType = molType
}
// Validate sort policy
if !filter.SortPolicy.IsValid() {
fmt.Fprintf(os.Stderr, "Error: invalid sort policy '%s'. Valid values: hybrid, priority, oldest\n", sortPolicy)
os.Exit(1)
}
// If daemon is running, use RPC
if daemonClient != nil {
readyArgs := &rpc.ReadyArgs{
Assignee: assignee,
Unassigned: unassigned,
Type: issueType,
Limit: limit,
SortPolicy: sortPolicy,
Labels: labels,
LabelsAny: labelsAny,
ParentID: parentID,
MolType: molTypeStr,
IncludeDeferred: includeDeferred, // GH#820
}
if cmd.Flags().Changed("priority") {
priority, _ := cmd.Flags().GetInt("priority")
readyArgs.Priority = &priority
}
resp, err := daemonClient.Ready(readyArgs)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
var issues []*types.Issue
if err := json.Unmarshal(resp.Data, &issues); err != nil {
fmt.Fprintf(os.Stderr, "Error parsing response: %v\n", err)
os.Exit(1)
}
if jsonOutput {
if issues == nil {
issues = []*types.Issue{}
}
outputJSON(issues)
return
}
// Show upgrade notification if needed
maybeShowUpgradeNotification()
if len(issues) == 0 {
// Check if there are any open issues at all
statsResp, statsErr := daemonClient.Stats()
hasOpenIssues := false
if statsErr == nil {
var stats types.Statistics
if json.Unmarshal(statsResp.Data, &stats) == nil {
hasOpenIssues = stats.OpenIssues > 0 || stats.InProgressIssues > 0
}
}
if hasOpenIssues {
fmt.Printf("\n%s No ready work found (all issues have blocking dependencies)\n\n",
ui.RenderWarn("✨"))
} else {
fmt.Printf("\n%s No open issues\n\n", ui.RenderPass("✨"))
}
return
}
if prettyFormat {
displayPrettyList(issues, false)
} else {
fmt.Printf("\n%s Ready work (%d issues with no blockers):\n\n", ui.RenderAccent("📋"), len(issues))
for i, issue := range issues {
fmt.Printf("%d. [%s] [%s] %s: %s\n", i+1,
ui.RenderPriority(issue.Priority),
ui.RenderType(string(issue.IssueType)),
ui.RenderID(issue.ID), issue.Title)
if issue.EstimatedMinutes != nil {
fmt.Printf(" Estimate: %d min\n", *issue.EstimatedMinutes)
}
if issue.Assignee != "" {
fmt.Printf(" Assignee: %s\n", issue.Assignee)
}
}
fmt.Println()
}
return
}
// Direct mode
ctx := rootCtx
// Check database freshness before reading
// Skip check when using daemon (daemon auto-imports on staleness)
if daemonClient == nil {
if err := ensureDatabaseFresh(ctx); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}
issues, err := store.GetReadyWork(ctx, filter)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
// If no ready work found, check if git has issues and auto-import
if len(issues) == 0 {
if checkAndAutoImport(ctx, store) {
// Re-run the query after import
issues, err = store.GetReadyWork(ctx, filter)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}
}
if jsonOutput {
// Always output array, even if empty
if issues == nil {
issues = []*types.Issue{}
}
outputJSON(issues)
return
}
// Show upgrade notification if needed
maybeShowUpgradeNotification()
if len(issues) == 0 {
// Check if there are any open issues at all
hasOpenIssues := false
if stats, statsErr := store.GetStatistics(ctx); statsErr == nil {
hasOpenIssues = stats.OpenIssues > 0 || stats.InProgressIssues > 0
}
if hasOpenIssues {
fmt.Printf("\n%s No ready work found (all issues have blocking dependencies)\n\n",
ui.RenderWarn("✨"))
} else {
fmt.Printf("\n%s No open issues\n\n", ui.RenderPass("✨"))
}
// Show tip even when no ready work found
maybeShowTip(store)
return
}
if prettyFormat {
displayPrettyList(issues, false)
} else {
fmt.Printf("\n%s Ready work (%d issues with no blockers):\n\n", ui.RenderAccent("📋"), len(issues))
for i, issue := range issues {
fmt.Printf("%d. [%s] [%s] %s: %s\n", i+1,
ui.RenderPriority(issue.Priority),
ui.RenderType(string(issue.IssueType)),
ui.RenderID(issue.ID), issue.Title)
if issue.EstimatedMinutes != nil {
fmt.Printf(" Estimate: %d min\n", *issue.EstimatedMinutes)
}
if issue.Assignee != "" {
fmt.Printf(" Assignee: %s\n", issue.Assignee)
}
}
fmt.Println()
}
// Show tip after successful ready (direct mode only)
maybeShowTip(store)
},
}
var blockedCmd = &cobra.Command{
Use: "blocked",
Short: "Show blocked issues",
Run: func(cmd *cobra.Command, args []string) {
// Use global jsonOutput set by PersistentPreRun (respects config.yaml + env vars)
// If daemon is running but doesn't support this command, use direct storage
ctx := rootCtx
if daemonClient != nil && store == nil {
var err error
store, err = sqlite.New(ctx, dbPath)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to open database: %v\n", err)
os.Exit(1)
}
defer func() { _ = store.Close() }()
}
parentID, _ := cmd.Flags().GetString("parent")
var blockedFilter types.WorkFilter
if parentID != "" {
blockedFilter.ParentID = &parentID
}
blocked, err := store.GetBlockedIssues(ctx, blockedFilter)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
if jsonOutput {
// Always output array, even if empty
if blocked == nil {
blocked = []*types.BlockedIssue{}
}
outputJSON(blocked)
return
}
if len(blocked) == 0 {
fmt.Printf("\n%s No blocked issues\n\n", ui.RenderPass("✨"))
return
}
fmt.Printf("\n%s Blocked issues (%d):\n\n", ui.RenderFail("🚫"), len(blocked))
for _, issue := range blocked {
fmt.Printf("[%s] %s: %s\n",
ui.RenderPriority(issue.Priority),
ui.RenderID(issue.ID), issue.Title)
blockedBy := issue.BlockedBy
if blockedBy == nil {
blockedBy = []string{}
}
fmt.Printf(" Blocked by %d open dependencies: %v\n",
issue.BlockedByCount, blockedBy)
fmt.Println()
}
},
}
// runMoleculeReady shows ready steps within a specific molecule
func runMoleculeReady(_ *cobra.Command, molIDArg string) {
ctx := rootCtx
// Molecule-ready requires direct store access for subgraph loading
if store == nil {
if daemonClient != nil {
fmt.Fprintf(os.Stderr, "Error: bd ready --mol requires direct database access\n")
fmt.Fprintf(os.Stderr, "Hint: use --no-daemon flag: bd --no-daemon ready --mol %s\n", molIDArg)
} else {
fmt.Fprintf(os.Stderr, "Error: no database connection\n")
}
os.Exit(1)
}
// Resolve molecule ID
moleculeID, err := utils.ResolvePartialID(ctx, store, molIDArg)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: molecule '%s' not found\n", molIDArg)
os.Exit(1)
}
// Load molecule subgraph
subgraph, err := loadTemplateSubgraph(ctx, store, moleculeID)
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading molecule: %v\n", err)
os.Exit(1)
}
// Get parallel analysis to find ready steps
analysis := analyzeMoleculeParallel(subgraph)
// Collect ready steps
var readySteps []*MoleculeReadyStep
for _, issue := range subgraph.Issues {
info := analysis.Steps[issue.ID]
if info != nil && info.IsReady {
readySteps = append(readySteps, &MoleculeReadyStep{
Issue: issue,
ParallelInfo: info,
ParallelGroup: info.ParallelGroup,
})
}
}
if jsonOutput {
output := MoleculeReadyOutput{
MoleculeID: moleculeID,
MoleculeTitle: subgraph.Root.Title,
TotalSteps: analysis.TotalSteps,
ReadySteps: len(readySteps),
Steps: readySteps,
ParallelGroups: analysis.ParallelGroups,
}
outputJSON(output)
return
}
// Human-readable output
fmt.Printf("\n%s Ready steps in molecule: %s\n", ui.RenderAccent("🧪"), subgraph.Root.Title)
fmt.Printf(" ID: %s\n", moleculeID)
fmt.Printf(" Total: %d steps, %d ready\n", analysis.TotalSteps, len(readySteps))
if len(readySteps) == 0 {
fmt.Printf("\n%s No ready steps (all blocked or completed)\n\n", ui.RenderWarn("✨"))
return
}
// Show parallel groups if any
if len(analysis.ParallelGroups) > 0 {
fmt.Printf("\n%s Parallel Groups:\n", ui.RenderPass("⚡"))
for groupName, members := range analysis.ParallelGroups {
// Check if any members are ready
readyInGroup := 0
for _, id := range members {
if info := analysis.Steps[id]; info != nil && info.IsReady {
readyInGroup++
}
}
if readyInGroup > 0 {
fmt.Printf(" %s: %d ready\n", groupName, readyInGroup)
}
}
}
fmt.Printf("\n%s Ready steps:\n\n", ui.RenderPass("📋"))
for i, step := range readySteps {
// Show parallel group if in one
groupAnnotation := ""
if step.ParallelGroup != "" {
groupAnnotation = fmt.Sprintf(" [%s]", ui.RenderAccent(step.ParallelGroup))
}
fmt.Printf("%d. [%s] [%s] %s: %s%s\n", i+1,
ui.RenderPriority(step.Issue.Priority),
ui.RenderType(string(step.Issue.IssueType)),
ui.RenderID(step.Issue.ID),
step.Issue.Title,
groupAnnotation)
// Show what this step can parallelize with
if len(step.ParallelInfo.CanParallel) > 0 {
readyParallel := []string{}
for _, pID := range step.ParallelInfo.CanParallel {
if pInfo := analysis.Steps[pID]; pInfo != nil && pInfo.IsReady {
readyParallel = append(readyParallel, pID)
}
}
if len(readyParallel) > 0 {
fmt.Printf(" Can run with: %v\n", readyParallel)
}
}
}
fmt.Println()
}
// MoleculeReadyStep holds a ready step with its parallel info
type MoleculeReadyStep struct {
Issue *types.Issue `json:"issue"`
ParallelInfo *ParallelInfo `json:"parallel_info"`
ParallelGroup string `json:"parallel_group,omitempty"`
}
// MoleculeReadyOutput is the JSON output for bd ready --mol
type MoleculeReadyOutput struct {
MoleculeID string `json:"molecule_id"`
MoleculeTitle string `json:"molecule_title"`
TotalSteps int `json:"total_steps"`
ReadySteps int `json:"ready_steps"`
Steps []*MoleculeReadyStep `json:"steps"`
ParallelGroups map[string][]string `json:"parallel_groups"`
}
func init() {
readyCmd.Flags().IntP("limit", "n", 10, "Maximum issues to show")
readyCmd.Flags().IntP("priority", "p", 0, "Filter by priority")
readyCmd.Flags().StringP("assignee", "a", "", "Filter by assignee")
readyCmd.Flags().BoolP("unassigned", "u", false, "Show only unassigned issues")
readyCmd.Flags().StringP("sort", "s", "hybrid", "Sort policy: hybrid (default), priority, oldest")
readyCmd.Flags().StringSliceP("label", "l", []string{}, "Filter by labels (AND: must have ALL). Can combine with --label-any")
readyCmd.Flags().StringSlice("label-any", []string{}, "Filter by labels (OR: must have AT LEAST ONE). Can combine with --label")
readyCmd.Flags().StringP("type", "t", "", "Filter by issue type (task, bug, feature, epic, merge-request). Aliases: mr→merge-request, feat→feature, mol→molecule")
readyCmd.Flags().String("mol", "", "Filter to steps within a specific molecule")
readyCmd.Flags().String("parent", "", "Filter to descendants of this bead/epic")
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)
}