feat(spawn): implement ephemeral molecule bonding
When --molecule is specified with gt spawn: - Generate ephemeral instance ID (eph-abc123 format) - Ensure .beads-ephemeral/ directory exists and is initialized - Create ephemeral parent issue linking back to source issue - Instantiate molecule steps in ephemeral beads - Include ephemeral context in work assignment mail This implements gt-3x0z.4 (Phase 2.1: gt spawn --molecule bonds in ephemeral). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,8 +2,11 @@ package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -247,10 +250,11 @@ func runSpawn(cmd *cobra.Command, args []string) error {
|
||||
|
||||
// Handle molecule instantiation if specified
|
||||
if spawnMolecule != "" {
|
||||
b := beads.New(beadsPath)
|
||||
// Use main beads to get the molecule template and parent issue
|
||||
mainBeads := beads.New(beadsPath)
|
||||
|
||||
// Get the molecule
|
||||
mol, err := b.Show(spawnMolecule)
|
||||
// Get the molecule template from main beads
|
||||
mol, err := mainBeads.Show(spawnMolecule)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting molecule %s: %w", spawnMolecule, err)
|
||||
}
|
||||
@@ -264,20 +268,49 @@ func runSpawn(cmd *cobra.Command, args []string) error {
|
||||
return fmt.Errorf("invalid molecule: %w", err)
|
||||
}
|
||||
|
||||
// Get the parent issue
|
||||
parent, err := b.Show(spawnIssue)
|
||||
// Get the parent issue from main beads
|
||||
parent, err := mainBeads.Show(spawnIssue)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting parent issue %s: %w", spawnIssue, err)
|
||||
}
|
||||
|
||||
// Instantiate the molecule
|
||||
fmt.Printf("Instantiating molecule %s on %s...\n", spawnMolecule, spawnIssue)
|
||||
steps, err := b.InstantiateMolecule(mol, parent, beads.InstantiateOptions{})
|
||||
// Generate ephemeral instance ID
|
||||
ephInstanceID, err := generateEphemeralInstanceID()
|
||||
if err != nil {
|
||||
return fmt.Errorf("instantiating molecule: %w", err)
|
||||
return fmt.Errorf("generating ephemeral instance ID: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("%s Created %d steps\n", style.Bold.Render("✓"), len(steps))
|
||||
// Ensure ephemeral beads directory exists
|
||||
ephemeralPath, err := ensureEphemeralBeadsDir(r.Path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("setting up ephemeral beads: %w", err)
|
||||
}
|
||||
|
||||
// Create ephemeral beads instance for molecule instantiation
|
||||
ephBeads := beads.New(ephemeralPath)
|
||||
|
||||
// Create an ephemeral parent issue to hold the molecule steps
|
||||
// This links back to the source issue in main beads
|
||||
ephParentDesc := fmt.Sprintf("Ephemeral molecule execution.\n\nsource_issue: %s\nmolecule: %s\ninstance: %s",
|
||||
spawnIssue, spawnMolecule, ephInstanceID)
|
||||
ephParent, err := ephBeads.Create(beads.CreateOptions{
|
||||
Title: fmt.Sprintf("[%s] %s", ephInstanceID, parent.Title),
|
||||
Type: "task",
|
||||
Priority: parent.Priority,
|
||||
Description: ephParentDesc,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating ephemeral parent issue: %w", err)
|
||||
}
|
||||
|
||||
// Instantiate the molecule in ephemeral beads
|
||||
fmt.Printf("Bonding molecule %s on %s (ephemeral: %s)...\n", spawnMolecule, spawnIssue, ephInstanceID)
|
||||
steps, err := ephBeads.InstantiateMolecule(mol, ephParent, beads.InstantiateOptions{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("instantiating molecule in ephemeral: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("%s Bonded %d steps in ephemeral\n", style.Bold.Render("✓"), len(steps))
|
||||
for _, step := range steps {
|
||||
fmt.Printf(" %s: %s\n", style.Dim.Render(step.ID), step.Title)
|
||||
}
|
||||
@@ -297,17 +330,20 @@ func runSpawn(cmd *cobra.Command, args []string) error {
|
||||
return fmt.Errorf("no ready step found in molecule (all steps have dependencies)")
|
||||
}
|
||||
|
||||
// Build molecule context for work assignment
|
||||
// Build molecule context for work assignment with ephemeral info
|
||||
moleculeCtx = &MoleculeContext{
|
||||
MoleculeID: spawnMolecule,
|
||||
RootIssueID: spawnIssue, // Original issue is the molecule root
|
||||
TotalSteps: len(steps),
|
||||
StepNumber: stepNumber,
|
||||
MoleculeID: spawnMolecule,
|
||||
RootIssueID: spawnIssue, // Original source issue in main beads
|
||||
TotalSteps: len(steps),
|
||||
StepNumber: stepNumber,
|
||||
EphemeralInstanceID: ephInstanceID,
|
||||
}
|
||||
|
||||
// Switch to spawning on the first ready step
|
||||
// Note: The step is in ephemeral beads, but we still assign the source issue
|
||||
// in main beads to the polecat for tracking
|
||||
fmt.Printf("\nSpawning on first ready step: %s\n", firstReadyStep.ID)
|
||||
spawnIssue = firstReadyStep.ID
|
||||
// Keep spawnIssue as the source issue for assignment tracking
|
||||
}
|
||||
|
||||
// Get or create issue
|
||||
@@ -594,10 +630,11 @@ func buildSpawnContext(issue *BeadsIssue, message string) string {
|
||||
|
||||
// MoleculeContext contains information about a molecule workflow assignment.
|
||||
type MoleculeContext struct {
|
||||
MoleculeID string // The molecule template ID
|
||||
RootIssueID string // The parent issue (molecule root)
|
||||
TotalSteps int // Total number of steps in the molecule
|
||||
StepNumber int // Which step this is (1-indexed)
|
||||
MoleculeID string // The molecule template ID
|
||||
RootIssueID string // The parent issue (molecule root)
|
||||
TotalSteps int // Total number of steps in the molecule
|
||||
StepNumber int // Which step this is (1-indexed)
|
||||
EphemeralInstanceID string // Ephemeral instance ID (e.g., "eph-abc123")
|
||||
}
|
||||
|
||||
// buildWorkAssignmentMail creates a work assignment mail message for a polecat.
|
||||
@@ -609,7 +646,12 @@ func buildWorkAssignmentMail(issue *BeadsIssue, message, polecatAddress string,
|
||||
|
||||
if issue != nil {
|
||||
if moleculeCtx != nil {
|
||||
subject = fmt.Sprintf("🧬 Molecule Step %d/%d: %s", moleculeCtx.StepNumber, moleculeCtx.TotalSteps, issue.Title)
|
||||
if moleculeCtx.EphemeralInstanceID != "" {
|
||||
// Ephemeral molecule format per spec
|
||||
subject = fmt.Sprintf("📋 Work Assignment: %s [MOLECULE]", issue.Title)
|
||||
} else {
|
||||
subject = fmt.Sprintf("🧬 Molecule Step %d/%d: %s", moleculeCtx.StepNumber, moleculeCtx.TotalSteps, issue.Title)
|
||||
}
|
||||
} else {
|
||||
subject = fmt.Sprintf("📋 Work Assignment: %s", issue.Title)
|
||||
}
|
||||
@@ -635,9 +677,21 @@ func buildWorkAssignmentMail(issue *BeadsIssue, message, polecatAddress string,
|
||||
if moleculeCtx != nil {
|
||||
body.WriteString("\n## Molecule Workflow\n")
|
||||
body.WriteString(fmt.Sprintf("You are working on step %d of %d in molecule %s.\n", moleculeCtx.StepNumber, moleculeCtx.TotalSteps, moleculeCtx.MoleculeID))
|
||||
body.WriteString(fmt.Sprintf("Root issue: %s\n\n", moleculeCtx.RootIssueID))
|
||||
body.WriteString("**IMPORTANT**: This is part of a molecule workflow. After completing this step:\n")
|
||||
body.WriteString("1. Run `bd close " + issue.ID + "`\n")
|
||||
body.WriteString(fmt.Sprintf("Source issue: %s\n", moleculeCtx.RootIssueID))
|
||||
if moleculeCtx.EphemeralInstanceID != "" {
|
||||
body.WriteString(fmt.Sprintf("Molecule instance: %s (ephemeral)\n\n", moleculeCtx.EphemeralInstanceID))
|
||||
body.WriteString("**IMPORTANT**: This is an ephemeral molecule workflow.\n")
|
||||
body.WriteString("The molecule steps are tracked in `.beads-ephemeral/` (not main beads).\n")
|
||||
body.WriteString("When complete, generate a summary and squash the molecule.\n\n")
|
||||
} else {
|
||||
body.WriteString("\n")
|
||||
}
|
||||
body.WriteString("After completing this step:\n")
|
||||
if issue != nil {
|
||||
body.WriteString("1. Run `bd close " + issue.ID + "`\n")
|
||||
} else {
|
||||
body.WriteString("1. Run `bd close <step-id>`\n")
|
||||
}
|
||||
body.WriteString("2. Run `bd ready --parent " + moleculeCtx.RootIssueID + "` to find next ready steps\n")
|
||||
body.WriteString("3. If more steps are ready, continue working on them\n")
|
||||
body.WriteString("4. When all steps are done, run `gt done` to signal completion\n\n")
|
||||
@@ -674,3 +728,44 @@ func buildWorkAssignmentMail(issue *BeadsIssue, message, polecatAddress string,
|
||||
Type: mail.TypeTask,
|
||||
}
|
||||
}
|
||||
|
||||
// generateEphemeralInstanceID creates a unique ephemeral instance ID.
|
||||
// Format: "eph-" followed by 6 hex characters (e.g., "eph-abc123").
|
||||
func generateEphemeralInstanceID() (string, error) {
|
||||
b := make([]byte, 3)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", fmt.Errorf("generating random bytes: %w", err)
|
||||
}
|
||||
return "eph-" + hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
// ensureEphemeralBeadsDir ensures the ephemeral beads directory exists and is initialized.
|
||||
// Returns the path to the ephemeral beads directory.
|
||||
func ensureEphemeralBeadsDir(rigPath string) (string, error) {
|
||||
ephemeralPath := filepath.Join(rigPath, ".beads-ephemeral")
|
||||
|
||||
// Create directory if it doesn't exist
|
||||
if err := os.MkdirAll(ephemeralPath, 0755); err != nil {
|
||||
return "", fmt.Errorf("creating ephemeral beads dir: %w", err)
|
||||
}
|
||||
|
||||
// Check if it's initialized as a git repo
|
||||
gitDir := filepath.Join(ephemeralPath, ".git")
|
||||
if _, err := os.Stat(gitDir); os.IsNotExist(err) {
|
||||
// Initialize git repo
|
||||
cmd := exec.Command("git", "init")
|
||||
cmd.Dir = ephemeralPath
|
||||
if err := cmd.Run(); err != nil {
|
||||
return "", fmt.Errorf("initializing git in ephemeral dir: %w", err)
|
||||
}
|
||||
|
||||
// Create ephemeral config
|
||||
configPath := filepath.Join(ephemeralPath, "config.yaml")
|
||||
configContent := "ephemeral: true\n# No sync-branch - ephemeral is local only\n"
|
||||
if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil {
|
||||
return "", fmt.Errorf("creating ephemeral config: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return ephemeralPath, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user