Files
beads/cmd/bd/mol_current.go
aleiby 1472d115f1 fix(mol): find molecules attached to hooked issues (bd-o0mp) (#1302)
When no steps are in_progress, bd mol current now checks for molecules
bonded to hooked issues via blocks dependencies. This fixes the case
where a molecule is attached to an agent's hook but no steps have been
claimed yet.

The fix adds findHookedMolecules() as a fallback after findInProgressMolecules()
returns empty. It queries for hooked issues assigned to the agent and checks
for blocks dependencies pointing to molecules (epics or template-labeled issues).

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 17:11:44 -08:00

681 lines
19 KiB
Go

package main
import (
"context"
"encoding/json"
"fmt"
"os"
"sort"
"strconv"
"strings"
"github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/rpc"
"github.com/steveyegge/beads/internal/storage"
"github.com/steveyegge/beads/internal/types"
"github.com/steveyegge/beads/internal/ui"
"github.com/steveyegge/beads/internal/utils"
)
// LargeMoleculeThreshold is the step count above which we show summary instead of full list.
// This prevents overwhelming output and slow queries for mega-molecules.
const LargeMoleculeThreshold = 100
// MoleculeProgress holds the progress information for a molecule
type MoleculeProgress struct {
MoleculeID string `json:"molecule_id"`
MoleculeTitle string `json:"molecule_title"`
Assignee string `json:"assignee,omitempty"`
CurrentStep *types.Issue `json:"current_step,omitempty"`
NextStep *types.Issue `json:"next_step,omitempty"`
Steps []*StepStatus `json:"steps"`
Completed int `json:"completed"`
Total int `json:"total"`
}
// StepStatus represents the status of a step in a molecule
type StepStatus struct {
Issue *types.Issue `json:"issue"`
Status string `json:"status"` // "done", "current", "ready", "blocked", "pending"
IsCurrent bool `json:"is_current"` // true if this is the in_progress step
}
var molCurrentCmd = &cobra.Command{
Use: "current [molecule-id]",
Short: "Show current position in molecule workflow",
Long: `Show where you are in a molecule workflow.
If molecule-id is given, show status for that molecule.
If not given, infer from in_progress issues assigned to current agent.
The output shows all steps with status indicators:
[done] - Step is complete (closed)
[current] - Step is in_progress (you are here)
[ready] - Step is ready to start (unblocked)
[blocked] - Step is blocked by dependencies
[pending] - Step is waiting
For large molecules (>100 steps), a summary is shown instead.
Use --limit or --range to view specific steps:
bd mol current <id> --limit 50 # Show first 50 steps
bd mol current <id> --range 100-150 # Show steps 100-150`,
Args: cobra.MaximumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
ctx := rootCtx
forAgent, _ := cmd.Flags().GetString("for")
limit, _ := cmd.Flags().GetInt("limit")
rangeStr, _ := cmd.Flags().GetString("range")
// Determine who we're looking for
agent := forAgent
if agent == "" {
agent = actor // Default to current user/agent
}
// mol current requires direct store access for subgraph loading
if store == nil {
if daemonClient != nil {
fmt.Fprintf(os.Stderr, "Error: mol current requires direct database access\n")
fmt.Fprintf(os.Stderr, "Hint: use --no-daemon flag: bd --no-daemon mol current\n")
} else {
fmt.Fprintf(os.Stderr, "Error: no database connection\n")
}
os.Exit(1)
}
// Parse range flag if provided
var rangeStart, rangeEnd int
if rangeStr != "" {
var err error
rangeStart, rangeEnd, err = parseRange(rangeStr)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: invalid range '%s': %v\n", rangeStr, err)
os.Exit(1)
}
}
// Determine if user explicitly requested steps
explicitSteps := limit > 0 || rangeStr != ""
var molecules []*MoleculeProgress
if len(args) == 1 {
// Explicit molecule ID given
moleculeID, err := utils.ResolvePartialID(ctx, store, args[0])
if err != nil {
fmt.Fprintf(os.Stderr, "Error: molecule '%s' not found\n", args[0])
os.Exit(1)
}
// Check child count first for large molecule detection
stats, err := store.GetMoleculeProgress(ctx, moleculeID)
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading molecule: %v\n", err)
os.Exit(1)
}
// If large molecule and no explicit flags, show summary
if stats.Total > LargeMoleculeThreshold && !explicitSteps && !jsonOutput {
printLargeMoleculeSummary(stats)
return
}
progress, err := getMoleculeProgress(ctx, store, moleculeID)
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading molecule: %v\n", err)
os.Exit(1)
}
// Apply limit or range filtering
if rangeStr != "" {
progress.Steps = filterStepsByRange(progress.Steps, rangeStart, rangeEnd)
} else if limit > 0 && len(progress.Steps) > limit {
progress.Steps = progress.Steps[:limit]
}
molecules = append(molecules, progress)
} else {
// Infer from in_progress issues
molecules = findInProgressMolecules(ctx, store, agent)
// Fallback: check for hooked issues with bonded molecules
if len(molecules) == 0 {
molecules = findHookedMolecules(ctx, store, agent)
}
if len(molecules) == 0 {
if jsonOutput {
outputJSON([]interface{}{})
return
}
fmt.Printf("No molecules in progress")
if agent != "" {
fmt.Printf(" for %s", agent)
}
fmt.Println(".")
fmt.Println("\nTo start work on a molecule:")
fmt.Println(" bd mol pour <proto-id> # Instantiate a molecule from template")
fmt.Println(" bd update <step-id> --status in_progress # Claim a step")
return
}
}
if jsonOutput {
outputJSON(molecules)
return
}
// Human-readable output
for i, mol := range molecules {
if i > 0 {
fmt.Println()
}
printMoleculeProgress(mol)
}
},
}
// getMoleculeProgress loads a molecule and computes progress
func getMoleculeProgress(ctx context.Context, s storage.Storage, moleculeID string) (*MoleculeProgress, error) {
subgraph, err := loadTemplateSubgraph(ctx, s, moleculeID)
if err != nil {
return nil, err
}
progress := &MoleculeProgress{
MoleculeID: subgraph.Root.ID,
MoleculeTitle: subgraph.Root.Title,
Assignee: subgraph.Root.Assignee,
Total: len(subgraph.Issues) - 1, // Exclude root
}
// Get ready issues for this molecule
// IncludeMolSteps: true because we specifically need to see molecule steps here
readyIDs := make(map[string]bool)
readyIssues, err := s.GetReadyWork(ctx, types.WorkFilter{IncludeMolSteps: true})
if err == nil {
for _, issue := range readyIssues {
readyIDs[issue.ID] = true
}
}
// Build step status list (exclude root)
var steps []*StepStatus
for _, issue := range subgraph.Issues {
if issue.ID == subgraph.Root.ID {
continue // Skip root
}
step := &StepStatus{
Issue: issue,
}
switch issue.Status {
case types.StatusClosed:
step.Status = "done"
progress.Completed++
case types.StatusInProgress:
step.Status = "current"
step.IsCurrent = true
progress.CurrentStep = issue
case types.StatusBlocked:
step.Status = "blocked"
default:
// Check if ready (unblocked)
if readyIDs[issue.ID] {
step.Status = "ready"
if progress.NextStep == nil {
progress.NextStep = issue
}
} else {
step.Status = "pending"
}
}
steps = append(steps, step)
}
// Sort steps by dependency order
sortStepsByDependencyOrder(steps, subgraph)
progress.Steps = steps
// If no current step but there's a ready step, set it as next
if progress.CurrentStep == nil && progress.NextStep == nil {
for _, step := range steps {
if step.Status == "ready" {
progress.NextStep = step.Issue
break
}
}
}
return progress, nil
}
// findInProgressMolecules finds molecules with in_progress steps for an agent
func findInProgressMolecules(ctx context.Context, s storage.Storage, agent string) []*MoleculeProgress {
// Query for in_progress issues
var inProgressIssues []*types.Issue
if daemonClient != nil {
listArgs := &rpc.ListArgs{
Status: "in_progress",
Assignee: agent,
}
resp, err := daemonClient.List(listArgs)
if err == nil {
_ = json.Unmarshal(resp.Data, &inProgressIssues)
}
} else {
// Direct query - search for in_progress issues
status := types.StatusInProgress
filter := types.IssueFilter{Status: &status}
if agent != "" {
filter.Assignee = &agent
}
allIssues, err := s.SearchIssues(ctx, "", filter)
if err == nil {
inProgressIssues = allIssues
}
}
if len(inProgressIssues) == 0 {
return nil
}
// For each in_progress issue, find its parent molecule
moleculeMap := make(map[string]*MoleculeProgress)
for _, issue := range inProgressIssues {
moleculeID := findParentMolecule(ctx, s, issue.ID)
if moleculeID == "" {
// Not part of a molecule, skip
continue
}
if _, exists := moleculeMap[moleculeID]; !exists {
progress, err := getMoleculeProgress(ctx, s, moleculeID)
if err == nil {
moleculeMap[moleculeID] = progress
}
}
}
// Convert to slice
var molecules []*MoleculeProgress
for _, mol := range moleculeMap {
molecules = append(molecules, mol)
}
// Sort by molecule ID for consistent output
sort.Slice(molecules, func(i, j int) bool {
return molecules[i].MoleculeID < molecules[j].MoleculeID
})
return molecules
}
// findHookedMolecules finds molecules bonded to hooked issues for an agent.
// This is a fallback when no in_progress steps exist but a molecule is attached
// to the agent's hooked work via a "blocks" dependency.
func findHookedMolecules(ctx context.Context, s storage.Storage, agent string) []*MoleculeProgress {
// Query for hooked issues assigned to the agent
status := types.StatusHooked
filter := types.IssueFilter{Status: &status}
if agent != "" {
filter.Assignee = &agent
}
hookedIssues, err := s.SearchIssues(ctx, "", filter)
if err != nil || len(hookedIssues) == 0 {
return nil
}
// For each hooked issue, check for blocks dependencies on molecules
moleculeMap := make(map[string]*MoleculeProgress)
for _, issue := range hookedIssues {
deps, err := s.GetDependencyRecords(ctx, issue.ID)
if err != nil {
continue
}
// Look for a blocks dependency pointing to a molecule (epic or template)
for _, dep := range deps {
if dep.Type != types.DepBlocks {
continue
}
// The issue depends on (is blocked by) dep.DependsOnID
candidate, err := s.GetIssue(ctx, dep.DependsOnID)
if err != nil || candidate == nil {
continue
}
// Check if candidate is a molecule (epic or has template label)
isMolecule := candidate.IssueType == types.TypeEpic
if !isMolecule {
for _, label := range candidate.Labels {
if label == BeadsTemplateLabel {
isMolecule = true
break
}
}
}
if isMolecule {
if _, exists := moleculeMap[candidate.ID]; !exists {
progress, err := getMoleculeProgress(ctx, s, candidate.ID)
if err == nil {
moleculeMap[candidate.ID] = progress
}
}
}
}
}
// Convert to slice
var molecules []*MoleculeProgress
for _, mol := range moleculeMap {
molecules = append(molecules, mol)
}
// Sort by molecule ID for consistent output
sort.Slice(molecules, func(i, j int) bool {
return molecules[i].MoleculeID < molecules[j].MoleculeID
})
return molecules
}
// findParentMolecule walks up parent-child chain to find the root molecule
func findParentMolecule(ctx context.Context, s storage.Storage, issueID string) string {
visited := make(map[string]bool)
currentID := issueID
for !visited[currentID] {
visited[currentID] = true
// Get dependencies for current issue
deps, err := s.GetDependencyRecords(ctx, currentID)
if err != nil {
return ""
}
// Find parent-child dependency where current is the child
var parentID string
for _, dep := range deps {
if dep.Type == types.DepParentChild && dep.IssueID == currentID {
parentID = dep.DependsOnID
break
}
}
if parentID == "" {
// No parent - check if current issue is a molecule root
issue, err := s.GetIssue(ctx, currentID)
if err != nil || issue == nil {
return ""
}
// Check if it has the template label (molecules are spawned from templates)
for _, label := range issue.Labels {
if label == BeadsTemplateLabel {
return currentID
}
}
// Also check if it's an epic with children (ad-hoc molecule)
if issue.IssueType == types.TypeEpic {
return currentID
}
return ""
}
currentID = parentID
}
return ""
}
// sortStepsByDependencyOrder sorts steps by their dependency order
func sortStepsByDependencyOrder(steps []*StepStatus, subgraph *TemplateSubgraph) {
// Build dependency graph
depCount := make(map[string]int) // issue ID -> number of deps
for _, step := range steps {
depCount[step.Issue.ID] = 0
}
// Count blocking dependencies within the step set
stepIDs := make(map[string]bool)
for _, step := range steps {
stepIDs[step.Issue.ID] = true
}
for _, dep := range subgraph.Dependencies {
if dep.Type == types.DepBlocks && stepIDs[dep.IssueID] && stepIDs[dep.DependsOnID] {
depCount[dep.IssueID]++
}
}
// Stable sort by dependency count (fewer deps first)
sort.SliceStable(steps, func(i, j int) bool {
return depCount[steps[i].Issue.ID] < depCount[steps[j].Issue.ID]
})
}
// printMoleculeProgress prints the progress in human-readable format
func printMoleculeProgress(mol *MoleculeProgress) {
fmt.Printf("You're working on molecule %s\n", ui.RenderAccent(mol.MoleculeID))
fmt.Printf(" %s\n", mol.MoleculeTitle)
if mol.Assignee != "" {
fmt.Printf(" Assigned to: %s\n", mol.Assignee)
}
fmt.Println()
for _, step := range mol.Steps {
statusIcon := getStatusIcon(step.Status)
marker := ""
if step.IsCurrent {
marker = " <- YOU ARE HERE"
}
fmt.Printf(" %s %s: %s%s\n", statusIcon, step.Issue.ID, step.Issue.Title, marker)
}
fmt.Println()
fmt.Printf("Progress: %d/%d steps complete\n", mol.Completed, mol.Total)
if mol.NextStep != nil && mol.CurrentStep == nil {
fmt.Printf("\nNext ready: %s - %s\n", mol.NextStep.ID, mol.NextStep.Title)
fmt.Printf(" Start with: bd update %s --status in_progress\n", mol.NextStep.ID)
}
}
// getStatusIcon returns the icon for a step status
func getStatusIcon(status string) string {
switch status {
case "done":
return ui.RenderPass("[done]")
case "current":
return ui.RenderWarn("[current]")
case "ready":
return ui.RenderAccent("[ready]")
case "blocked":
return ui.RenderFail("[blocked]")
default:
return "[pending]"
}
}
// ContinueResult holds the result of advancing to the next molecule step
type ContinueResult struct {
ClosedStep *types.Issue `json:"closed_step"`
NextStep *types.Issue `json:"next_step,omitempty"`
AutoAdvanced bool `json:"auto_advanced"`
MolComplete bool `json:"molecule_complete"`
MoleculeID string `json:"molecule_id,omitempty"`
}
// AdvanceToNextStep finds the next ready step in a molecule after closing a step
// If autoClaim is true, it marks the next step as in_progress
// Returns nil if the issue is not part of a molecule
func AdvanceToNextStep(ctx context.Context, s storage.Storage, closedStepID string, autoClaim bool, actorName string) (*ContinueResult, error) {
if s == nil {
return nil, fmt.Errorf("no database connection")
}
// Get the closed step
closedStep, err := s.GetIssue(ctx, closedStepID)
if err != nil || closedStep == nil {
return nil, fmt.Errorf("could not get closed step: %w", err)
}
result := &ContinueResult{
ClosedStep: closedStep,
}
// Find parent molecule
moleculeID := findParentMolecule(ctx, s, closedStepID)
if moleculeID == "" {
// Not part of a molecule - nothing to advance
return nil, nil
}
result.MoleculeID = moleculeID
// Load molecule progress
progress, err := getMoleculeProgress(ctx, s, moleculeID)
if err != nil {
return nil, fmt.Errorf("could not load molecule: %w", err)
}
// Check if molecule is complete
if progress.Completed >= progress.Total {
result.MolComplete = true
return result, nil
}
// Find next ready step
var nextStep *types.Issue
for _, step := range progress.Steps {
if step.Status == "ready" {
nextStep = step.Issue
break
}
}
if nextStep == nil {
// No ready steps - might be blocked
return result, nil
}
result.NextStep = nextStep
// Auto-claim if requested
if autoClaim {
updates := map[string]interface{}{
"status": types.StatusInProgress,
}
if err := s.UpdateIssue(ctx, nextStep.ID, updates, actorName); err != nil {
return result, fmt.Errorf("could not claim next step: %w", err)
}
result.AutoAdvanced = true
}
return result, nil
}
// PrintContinueResult prints the result of advancing to the next step
func PrintContinueResult(result *ContinueResult) {
if result == nil {
return
}
if result.MolComplete {
fmt.Printf("\n%s Molecule %s complete! All steps closed.\n", ui.RenderPass("✓"), result.MoleculeID)
fmt.Println("Consider: bd mol squash " + result.MoleculeID + " --summary '...'")
return
}
if result.NextStep == nil {
fmt.Println("\nNo ready steps in molecule (may be blocked).")
return
}
fmt.Printf("\nNext ready in molecule:\n")
fmt.Printf(" %s: %s\n", result.NextStep.ID, result.NextStep.Title)
if result.AutoAdvanced {
fmt.Printf("\n%s Marked in_progress (use --no-auto to skip)\n", ui.RenderWarn("→"))
} else {
fmt.Printf("\nStart with: bd update %s --status in_progress\n", result.NextStep.ID)
}
}
// parseRange parses a range string like "1-50" or "100-150" into start and end indices.
// Returns 1-based indices (start=1 means first step).
func parseRange(rangeStr string) (start, end int, err error) {
parts := strings.Split(rangeStr, "-")
if len(parts) != 2 {
return 0, 0, fmt.Errorf("expected format 'start-end' (e.g., '1-50')")
}
start, err = strconv.Atoi(strings.TrimSpace(parts[0]))
if err != nil {
return 0, 0, fmt.Errorf("invalid start: %w", err)
}
end, err = strconv.Atoi(strings.TrimSpace(parts[1]))
if err != nil {
return 0, 0, fmt.Errorf("invalid end: %w", err)
}
if start < 1 {
return 0, 0, fmt.Errorf("start must be >= 1")
}
if end < start {
return 0, 0, fmt.Errorf("end must be >= start")
}
return start, end, nil
}
// filterStepsByRange filters steps to a 1-based range [start, end].
func filterStepsByRange(steps []*StepStatus, start, end int) []*StepStatus {
// Convert to 0-based indices
startIdx := start - 1
endIdx := end
if startIdx >= len(steps) {
return nil
}
if endIdx > len(steps) {
endIdx = len(steps)
}
return steps[startIdx:endIdx]
}
// printLargeMoleculeSummary prints a summary for molecules with many steps.
func printLargeMoleculeSummary(stats *types.MoleculeProgressStats) {
fmt.Printf("Molecule: %s\n", ui.RenderAccent(stats.MoleculeID))
fmt.Printf(" %s\n", stats.MoleculeTitle)
fmt.Println()
// Progress summary
var percent float64
if stats.Total > 0 {
percent = float64(stats.Completed) * 100 / float64(stats.Total)
}
fmt.Printf("Progress: %d / %d steps (%.1f%%)\n", stats.Completed, stats.Total, percent)
if stats.CurrentStepID != "" {
fmt.Printf("Current step: %s\n", stats.CurrentStepID)
} else if stats.InProgress > 0 {
fmt.Printf("In progress: %d step(s)\n", stats.InProgress)
}
fmt.Println()
fmt.Printf("%s This molecule has %d steps (threshold: %d).\n",
ui.RenderWarn("Note:"), stats.Total, LargeMoleculeThreshold)
fmt.Println("To view steps, use one of:")
fmt.Printf(" bd mol current %s --limit 50 # First 50 steps\n", stats.MoleculeID)
fmt.Printf(" bd mol current %s --range 1-50 # Steps 1-50\n", stats.MoleculeID)
fmt.Printf(" bd mol progress %s # Efficient progress summary\n", stats.MoleculeID)
}
func init() {
molCurrentCmd.Flags().String("for", "", "Show molecules for a specific agent/assignee")
molCurrentCmd.Flags().Int("limit", 0, "Maximum number of steps to display (0 = auto, use 'all' threshold)")
molCurrentCmd.Flags().String("range", "", "Display specific step range (e.g., '1-50', '100-150')")
molCmd.AddCommand(molCurrentCmd)
}