Files
gastown/internal/beads/molecule.go
Steve Yegge 0085353056 Add await-signal molecule step type with backoff support (gt-l6ro3.3)
Adds Type and Backoff fields to MoleculeStep for patrol agents to implement
cost-saving await-signal patterns:

- Type field: "task" (default), "wait" (await-signal), etc.
- BackoffConfig: base interval, multiplier, max cap
- Parsing for "Type:" and "Backoff:" lines in step definitions
- Comprehensive tests for new parsing functionality

Step definition format:
  ## Step: await-signal
  Type: wait
  Backoff: base=30s, multiplier=2, max=10m

Agents interpret these declaratively, implementing backoff behavior at runtime.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 18:07:39 -08:00

574 lines
16 KiB
Go

// Package beads molecule support - composable workflow templates.
package beads
import (
"fmt"
"regexp"
"strconv"
"strings"
)
// MoleculeStep represents a parsed step from a molecule definition.
type MoleculeStep struct {
Ref string // Step reference (from "## Step: <ref>")
Title string // Step title (first non-empty line or ref)
Instructions string // Prose instructions for this step
Needs []string // Step refs this step depends on
WaitsFor []string // Dynamic wait conditions (e.g., "all-children")
Tier string // Optional tier hint: haiku, sonnet, opus
Type string // Step type: "task" (default), "wait", etc.
Backoff *BackoffConfig // Backoff configuration for wait-type steps
}
// BackoffConfig defines exponential backoff parameters for wait-type steps.
// Used by patrol agents to implement cost-saving await-signal patterns.
type BackoffConfig struct {
Base string // Base interval (e.g., "30s")
Multiplier int // Multiplier for exponential growth (default: 2)
Max string // Maximum interval cap (e.g., "10m")
}
// stepHeaderRegex matches "## Step: <ref>" with optional whitespace.
var stepHeaderRegex = regexp.MustCompile(`(?i)^##\s*Step:\s*(\S+)\s*$`)
// needsLineRegex matches "Needs: step1, step2, ..." lines.
var needsLineRegex = regexp.MustCompile(`(?i)^Needs:\s*(.+)$`)
// tierLineRegex matches "Tier: haiku|sonnet|opus" lines.
var tierLineRegex = regexp.MustCompile(`(?i)^Tier:\s*(haiku|sonnet|opus)\s*$`)
// waitsForLineRegex matches "WaitsFor: condition1, condition2, ..." lines.
// Common conditions: "all-children" (fanout gate for dynamically bonded children)
var waitsForLineRegex = regexp.MustCompile(`(?i)^WaitsFor:\s*(.+)$`)
// typeLineRegex matches "Type: task|wait|..." lines.
// Common types: "task" (default), "wait" (await-signal with backoff)
var typeLineRegex = regexp.MustCompile(`(?i)^Type:\s*(\w+)\s*$`)
// backoffLineRegex matches "Backoff: base=30s, multiplier=2, max=10m" lines.
// Parses backoff configuration for wait-type steps.
var backoffLineRegex = regexp.MustCompile(`(?i)^Backoff:\s*(.+)$`)
// templateVarRegex matches {{variable}} placeholders.
var templateVarRegex = regexp.MustCompile(`\{\{(\w+)\}\}`)
// ParseMoleculeSteps extracts step definitions from a molecule's description.
//
// The expected format is:
//
// ## Step: <ref>
// <prose instructions>
// Needs: <step>, <step> # optional
// Tier: haiku|sonnet|opus # optional
// Type: task|wait # optional, default is "task"
// Backoff: base=30s, multiplier=2, max=10m # optional, for wait-type steps
//
// Returns an empty slice if no steps are found.
func ParseMoleculeSteps(description string) ([]MoleculeStep, error) {
if description == "" {
return nil, nil
}
lines := strings.Split(description, "\n")
var steps []MoleculeStep
var currentStep *MoleculeStep
var contentLines []string
// Helper to finalize current step
finalizeStep := func() {
if currentStep == nil {
return
}
// Process content lines to extract Needs/Tier and build instructions
var instructionLines []string
for _, line := range contentLines {
trimmed := strings.TrimSpace(line)
// Check for Needs: line
if matches := needsLineRegex.FindStringSubmatch(trimmed); matches != nil {
deps := strings.Split(matches[1], ",")
for _, dep := range deps {
dep = strings.TrimSpace(dep)
if dep != "" {
currentStep.Needs = append(currentStep.Needs, dep)
}
}
continue
}
// Check for Tier: line
if matches := tierLineRegex.FindStringSubmatch(trimmed); matches != nil {
currentStep.Tier = strings.ToLower(matches[1])
continue
}
// Check for WaitsFor: line
if matches := waitsForLineRegex.FindStringSubmatch(trimmed); matches != nil {
conditions := strings.Split(matches[1], ",")
for _, cond := range conditions {
cond = strings.TrimSpace(cond)
if cond != "" {
currentStep.WaitsFor = append(currentStep.WaitsFor, cond)
}
}
continue
}
// Check for Type: line
if matches := typeLineRegex.FindStringSubmatch(trimmed); matches != nil {
currentStep.Type = strings.ToLower(matches[1])
continue
}
// Check for Backoff: line
if matches := backoffLineRegex.FindStringSubmatch(trimmed); matches != nil {
currentStep.Backoff = parseBackoffConfig(matches[1])
continue
}
// Regular instruction line
instructionLines = append(instructionLines, line)
}
// Build instructions, trimming leading/trailing blank lines
currentStep.Instructions = strings.TrimSpace(strings.Join(instructionLines, "\n"))
// Set title from first non-empty line of instructions, or use ref
if currentStep.Instructions != "" {
firstLine := strings.SplitN(currentStep.Instructions, "\n", 2)[0]
currentStep.Title = strings.TrimSpace(firstLine)
}
if currentStep.Title == "" {
currentStep.Title = currentStep.Ref
}
steps = append(steps, *currentStep)
currentStep = nil
contentLines = nil
}
for _, line := range lines {
// Check for step header
if matches := stepHeaderRegex.FindStringSubmatch(line); matches != nil {
// Finalize previous step if any
finalizeStep()
// Start new step
currentStep = &MoleculeStep{
Ref: matches[1],
}
contentLines = nil
continue
}
// Accumulate content lines if we're in a step
if currentStep != nil {
contentLines = append(contentLines, line)
}
}
// Finalize last step
finalizeStep()
return steps, nil
}
// parseBackoffConfig parses a backoff configuration string.
// Expected format: "base=30s, multiplier=2, max=10m"
// Returns nil if parsing fails.
func parseBackoffConfig(configStr string) *BackoffConfig {
cfg := &BackoffConfig{
Multiplier: 2, // Default multiplier
}
// Split by comma and parse key=value pairs
parts := strings.Split(configStr, ",")
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
// Split by = to get key and value
kv := strings.SplitN(part, "=", 2)
if len(kv) != 2 {
continue
}
key := strings.TrimSpace(strings.ToLower(kv[0]))
value := strings.TrimSpace(kv[1])
switch key {
case "base":
cfg.Base = value
case "multiplier":
if m, err := strconv.Atoi(value); err == nil {
cfg.Multiplier = m
}
case "max":
cfg.Max = value
}
}
// Return nil if no base was specified (incomplete config)
if cfg.Base == "" {
return nil
}
return cfg
}
// ExpandTemplateVars replaces {{variable}} placeholders in text using the provided context map.
// Unknown variables are left as-is.
func ExpandTemplateVars(text string, ctx map[string]string) string {
if ctx == nil {
return text
}
return templateVarRegex.ReplaceAllStringFunc(text, func(match string) string {
// Extract variable name from {{name}}
varName := match[2 : len(match)-2]
if value, ok := ctx[varName]; ok {
return value
}
return match // Leave unknown variables as-is
})
}
// InstantiateOptions configures molecule instantiation behavior.
type InstantiateOptions struct {
// Context map for {{variable}} substitution
Context map[string]string
}
// InstantiateMolecule creates child issues from a molecule template.
//
// This function supports two molecule formats (format bridge pattern):
//
// 1. New format (child issues): If the molecule proto has child issues,
// those children are used as templates. Dependencies are copied from
// the template children's DependsOn relationships.
//
// 2. Old format (embedded markdown): If the molecule has no children,
// steps are parsed from the Description field using ParseMoleculeSteps().
// Dependencies are extracted from "Needs:" declarations in the markdown.
//
// For each step, this creates:
// - A child issue with ID "{parent.ID}.{step.Ref}"
// - Title from step title
// - Description from step instructions (with template vars expanded)
// - Type: task
// - Priority: inherited from parent
// - Dependencies wired according to template
//
// The function is atomic via bd CLI - either all issues are created or none.
// Returns the created step issues.
func (b *Beads) InstantiateMolecule(mol *Issue, parent *Issue, opts InstantiateOptions) ([]*Issue, error) {
if mol == nil {
return nil, fmt.Errorf("molecule issue is nil")
}
if parent == nil {
return nil, fmt.Errorf("parent issue is nil")
}
// FORMAT BRIDGE: Try new format first (child issues), fall back to old format (markdown)
templateChildren, err := b.List(ListOptions{
Parent: mol.ID,
Status: "all",
Priority: -1,
})
if err != nil {
// Non-fatal - might not have children, continue to old format
templateChildren = nil
}
if len(templateChildren) > 0 {
// NEW FORMAT: Use child issues as templates
return b.instantiateFromChildren(mol, parent, templateChildren, opts)
}
// OLD FORMAT: Parse steps from molecule description
return b.instantiateFromMarkdown(mol, parent, opts)
}
// instantiateFromChildren creates steps from template child issues (new format).
func (b *Beads) instantiateFromChildren(mol *Issue, parent *Issue, templates []*Issue, opts InstantiateOptions) ([]*Issue, error) {
var createdIssues []*Issue
templateToNew := make(map[string]string) // template ID -> new issue ID
// First pass: create all child issues
for _, tmpl := range templates {
// Expand template variables in description
description := tmpl.Description
if opts.Context != nil {
description = ExpandTemplateVars(description, opts.Context)
}
// Add provenance metadata
if description != "" {
description += "\n\n"
}
description += fmt.Sprintf("instantiated_from: %s\ntemplate_step: %s", mol.ID, tmpl.ID)
// Create the child issue
childOpts := CreateOptions{
Title: tmpl.Title,
Type: tmpl.Type,
Priority: parent.Priority,
Description: description,
Parent: parent.ID,
}
if childOpts.Type == "" {
childOpts.Type = "task"
}
child, err := b.Create(childOpts)
if err != nil {
// Attempt to clean up created issues on failure (best-effort cleanup)
for _, created := range createdIssues {
_ = b.Close(created.ID)
}
return nil, fmt.Errorf("creating step from template %q: %w", tmpl.ID, err)
}
createdIssues = append(createdIssues, child)
templateToNew[tmpl.ID] = child.ID
}
// Second pass: wire dependencies based on template dependencies
for _, tmpl := range templates {
if len(tmpl.DependsOn) == 0 {
continue
}
newChildID := templateToNew[tmpl.ID]
for _, depTemplateID := range tmpl.DependsOn {
newDepID, ok := templateToNew[depTemplateID]
if !ok {
// Dependency points outside the template - skip
continue
}
if err := b.AddDependency(newChildID, newDepID); err != nil {
// Log but don't fail - the issues are created
return createdIssues, fmt.Errorf("adding dependency %s -> %s: %w", newChildID, newDepID, err)
}
}
}
return createdIssues, nil
}
// instantiateFromMarkdown creates steps from embedded markdown (old format).
func (b *Beads) instantiateFromMarkdown(mol *Issue, parent *Issue, opts InstantiateOptions) ([]*Issue, error) {
// Parse steps from molecule
steps, err := ParseMoleculeSteps(mol.Description)
if err != nil {
return nil, fmt.Errorf("parsing molecule steps: %w", err)
}
if len(steps) == 0 {
return nil, fmt.Errorf("molecule has no steps defined")
}
// Build map of step ref -> step for dependency validation
stepMap := make(map[string]*MoleculeStep)
for i := range steps {
stepMap[steps[i].Ref] = &steps[i]
}
// Validate all Needs references exist
for _, step := range steps {
for _, need := range step.Needs {
if _, ok := stepMap[need]; !ok {
return nil, fmt.Errorf("step %q depends on unknown step %q", step.Ref, need)
}
}
}
// Create child issues for each step
var createdIssues []*Issue
stepIssueIDs := make(map[string]string) // step ref -> issue ID
for _, step := range steps {
// Expand template variables in instructions
instructions := step.Instructions
if opts.Context != nil {
instructions = ExpandTemplateVars(instructions, opts.Context)
}
// Build description with provenance metadata
description := instructions
if description != "" {
description += "\n\n"
}
description += fmt.Sprintf("instantiated_from: %s\nstep: %s", mol.ID, step.Ref)
if step.Tier != "" {
description += fmt.Sprintf("\ntier: %s", step.Tier)
}
// Create the child issue
childOpts := CreateOptions{
Title: step.Title,
Type: "task",
Priority: parent.Priority,
Description: description,
Parent: parent.ID,
}
child, err := b.Create(childOpts)
if err != nil {
// Attempt to clean up created issues on failure (best-effort cleanup)
for _, created := range createdIssues {
_ = b.Close(created.ID)
}
return nil, fmt.Errorf("creating step %q: %w", step.Ref, err)
}
createdIssues = append(createdIssues, child)
stepIssueIDs[step.Ref] = child.ID
}
// Wire inter-step dependencies based on Needs: declarations
for _, step := range steps {
if len(step.Needs) == 0 {
continue
}
childID := stepIssueIDs[step.Ref]
for _, need := range step.Needs {
dependsOnID := stepIssueIDs[need]
if err := b.AddDependency(childID, dependsOnID); err != nil {
// Log but don't fail - the issues are created
// This is non-atomic but bd CLI doesn't support transactions
return createdIssues, fmt.Errorf("adding dependency %s -> %s: %w", childID, dependsOnID, err)
}
}
}
return createdIssues, nil
}
// ValidateMolecule checks if an issue is a valid molecule definition.
// Returns an error describing the problem, or nil if valid.
//
// Note: This function only validates the old format (embedded markdown steps).
// For new format molecules (with child issues), validation is implicit during
// instantiation - if the molecule has children, those are used as templates.
// Use InstantiateMolecule directly for new format molecules; this function
// will report "no steps defined" for new format molecules since it cannot
// access child issues without a Beads client.
func ValidateMolecule(mol *Issue) error {
if mol == nil {
return fmt.Errorf("molecule is nil")
}
if mol.Type != "molecule" {
return fmt.Errorf("issue type is %q, expected molecule", mol.Type)
}
steps, err := ParseMoleculeSteps(mol.Description)
if err != nil {
return fmt.Errorf("parsing steps: %w", err)
}
if len(steps) == 0 {
return fmt.Errorf("molecule has no steps defined")
}
// Build step map for reference validation
stepMap := make(map[string]bool)
for _, step := range steps {
if step.Ref == "" {
return fmt.Errorf("step has empty ref")
}
if stepMap[step.Ref] {
return fmt.Errorf("duplicate step ref: %s", step.Ref)
}
stepMap[step.Ref] = true
}
// Validate Needs references
for _, step := range steps {
for _, need := range step.Needs {
if !stepMap[need] {
return fmt.Errorf("step %q depends on unknown step %q", step.Ref, need)
}
if need == step.Ref {
return fmt.Errorf("step %q has self-dependency", step.Ref)
}
}
}
// Detect cycles in dependency graph
if err := detectCycles(steps); err != nil {
return err
}
return nil
}
// detectCycles checks for circular dependencies in the step graph using DFS.
// Returns an error describing the cycle if one is found.
func detectCycles(steps []MoleculeStep) error {
// Build adjacency list: step -> steps it depends on
deps := make(map[string][]string)
for _, step := range steps {
deps[step.Ref] = step.Needs
}
// Track visit state: 0 = unvisited, 1 = visiting (in stack), 2 = visited
state := make(map[string]int)
// DFS from each node to find cycles
var path []string
var dfs func(node string) error
dfs = func(node string) error {
if state[node] == 2 {
return nil // Already fully processed
}
if state[node] == 1 {
// Found a back edge - there's a cycle
// Build cycle path for error message
cycleStart := -1
for i, n := range path {
if n == node {
cycleStart = i
break
}
}
cycle := append(path[cycleStart:], node)
return fmt.Errorf("cycle detected in step dependencies: %s", formatCycle(cycle))
}
state[node] = 1 // Mark as visiting
path = append(path, node)
for _, dep := range deps[node] {
if err := dfs(dep); err != nil {
return err
}
}
path = path[:len(path)-1] // Pop from path
state[node] = 2 // Mark as visited
return nil
}
for _, step := range steps {
if state[step.Ref] == 0 {
if err := dfs(step.Ref); err != nil {
return err
}
}
}
return nil
}
// formatCycle formats a cycle path as "a -> b -> c -> a".
func formatCycle(cycle []string) string {
return strings.Join(cycle, " -> ")
}