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 (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -247,10 +250,11 @@ func runSpawn(cmd *cobra.Command, args []string) error {
|
|||||||
|
|
||||||
// Handle molecule instantiation if specified
|
// Handle molecule instantiation if specified
|
||||||
if spawnMolecule != "" {
|
if spawnMolecule != "" {
|
||||||
b := beads.New(beadsPath)
|
// Use main beads to get the molecule template and parent issue
|
||||||
|
mainBeads := beads.New(beadsPath)
|
||||||
|
|
||||||
// Get the molecule
|
// Get the molecule template from main beads
|
||||||
mol, err := b.Show(spawnMolecule)
|
mol, err := mainBeads.Show(spawnMolecule)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("getting molecule %s: %w", spawnMolecule, err)
|
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)
|
return fmt.Errorf("invalid molecule: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the parent issue
|
// Get the parent issue from main beads
|
||||||
parent, err := b.Show(spawnIssue)
|
parent, err := mainBeads.Show(spawnIssue)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("getting parent issue %s: %w", spawnIssue, err)
|
return fmt.Errorf("getting parent issue %s: %w", spawnIssue, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Instantiate the molecule
|
// Generate ephemeral instance ID
|
||||||
fmt.Printf("Instantiating molecule %s on %s...\n", spawnMolecule, spawnIssue)
|
ephInstanceID, err := generateEphemeralInstanceID()
|
||||||
steps, err := b.InstantiateMolecule(mol, parent, beads.InstantiateOptions{})
|
|
||||||
if err != nil {
|
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 {
|
for _, step := range steps {
|
||||||
fmt.Printf(" %s: %s\n", style.Dim.Render(step.ID), step.Title)
|
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)")
|
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{
|
moleculeCtx = &MoleculeContext{
|
||||||
MoleculeID: spawnMolecule,
|
MoleculeID: spawnMolecule,
|
||||||
RootIssueID: spawnIssue, // Original issue is the molecule root
|
RootIssueID: spawnIssue, // Original source issue in main beads
|
||||||
TotalSteps: len(steps),
|
TotalSteps: len(steps),
|
||||||
StepNumber: stepNumber,
|
StepNumber: stepNumber,
|
||||||
|
EphemeralInstanceID: ephInstanceID,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Switch to spawning on the first ready step
|
// 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)
|
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
|
// Get or create issue
|
||||||
@@ -594,10 +630,11 @@ func buildSpawnContext(issue *BeadsIssue, message string) string {
|
|||||||
|
|
||||||
// MoleculeContext contains information about a molecule workflow assignment.
|
// MoleculeContext contains information about a molecule workflow assignment.
|
||||||
type MoleculeContext struct {
|
type MoleculeContext struct {
|
||||||
MoleculeID string // The molecule template ID
|
MoleculeID string // The molecule template ID
|
||||||
RootIssueID string // The parent issue (molecule root)
|
RootIssueID string // The parent issue (molecule root)
|
||||||
TotalSteps int // Total number of steps in the molecule
|
TotalSteps int // Total number of steps in the molecule
|
||||||
StepNumber int // Which step this is (1-indexed)
|
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.
|
// 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 issue != nil {
|
||||||
if moleculeCtx != 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 {
|
} else {
|
||||||
subject = fmt.Sprintf("📋 Work Assignment: %s", issue.Title)
|
subject = fmt.Sprintf("📋 Work Assignment: %s", issue.Title)
|
||||||
}
|
}
|
||||||
@@ -635,9 +677,21 @@ func buildWorkAssignmentMail(issue *BeadsIssue, message, polecatAddress string,
|
|||||||
if moleculeCtx != nil {
|
if moleculeCtx != nil {
|
||||||
body.WriteString("\n## Molecule Workflow\n")
|
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("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(fmt.Sprintf("Source issue: %s\n", moleculeCtx.RootIssueID))
|
||||||
body.WriteString("**IMPORTANT**: This is part of a molecule workflow. After completing this step:\n")
|
if moleculeCtx.EphemeralInstanceID != "" {
|
||||||
body.WriteString("1. Run `bd close " + issue.ID + "`\n")
|
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("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("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")
|
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,
|
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