Files
gastown/internal/cmd/molecule_lifecycle.go
Steve Yegge fe19c8d15e feat: Wire up created_by field for beads issues (gt-u6nri)
- Add CreatedBy field to Issue struct (matches beads GH#748)
- Add Actor field to CreateOptions, pass --actor to bd create
- Add ActorString() method to RoleInfo for identity formatting
- Update all beads.Create() callers to pass Actor
- Update direct bd create exec calls with --actor:
  - mail/router.go: uses sender identity
  - patrol_helpers.go: uses role name
  - doctor/patrol_check.go: uses "gt-doctor"
  - rig/manager.go: uses "gt-rig-init"

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 19:42:41 -08:00

574 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package cmd
import (
"encoding/json"
"fmt"
"os"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/beads"
"github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/workspace"
)
func runMoleculeInstantiate(cmd *cobra.Command, args []string) error {
molID := args[0]
workDir, err := findLocalBeadsDir()
if err != nil {
return fmt.Errorf("not in a beads workspace: %w", err)
}
b := beads.New(workDir)
// Try catalog first
catalog, err := loadMoleculeCatalog(workDir)
if err != nil {
return fmt.Errorf("loading catalog: %w", err)
}
var mol *beads.Issue
if catalogMol := catalog.Get(molID); catalogMol != nil {
mol = catalogMol.ToIssue()
} else {
// Fall back to database
mol, err = b.Show(molID)
if err != nil {
return fmt.Errorf("getting molecule: %w", err)
}
}
if mol.Type != "molecule" {
return fmt.Errorf("%s is not a molecule (type: %s)", molID, mol.Type)
}
// Validate molecule
if err := beads.ValidateMolecule(mol); err != nil {
return fmt.Errorf("invalid molecule: %w", err)
}
// Get the parent issue
parent, err := b.Show(moleculeInstParent)
if err != nil {
return fmt.Errorf("getting parent issue: %w", err)
}
// Parse context variables
ctx := make(map[string]string)
for _, kv := range moleculeInstContext {
parts := strings.SplitN(kv, "=", 2)
if len(parts) != 2 {
return fmt.Errorf("invalid context format %q (expected key=value)", kv)
}
ctx[parts[0]] = parts[1]
}
// Instantiate the molecule
opts := beads.InstantiateOptions{Context: ctx}
steps, err := b.InstantiateMolecule(mol, parent, opts)
if err != nil {
return fmt.Errorf("instantiating molecule: %w", err)
}
fmt.Printf("%s Created %d steps from %s on %s\n\n",
style.Bold.Render("✓"), len(steps), molID, moleculeInstParent)
for _, step := range steps {
fmt.Printf(" %s: %s\n", style.Dim.Render(step.ID), step.Title)
}
return nil
}
// runMoleculeBond dynamically bonds a child molecule to a running parent.
// This enables the Christmas Ornament pattern for parallel child execution.
func runMoleculeBond(cmd *cobra.Command, args []string) error {
protoID := args[0]
workDir, err := findLocalBeadsDir()
if err != nil {
return fmt.Errorf("not in a beads workspace: %w", err)
}
b := beads.New(workDir)
// Load the molecule proto from catalog
catalog, err := loadMoleculeCatalog(workDir)
if err != nil {
return fmt.Errorf("loading catalog: %w", err)
}
var proto *beads.Issue
if catalogMol := catalog.Get(protoID); catalogMol != nil {
proto = catalogMol.ToIssue()
} else {
// Fall back to database
proto, err = b.Show(protoID)
if err != nil {
return fmt.Errorf("getting molecule proto: %w", err)
}
}
if proto.Type != "molecule" {
return fmt.Errorf("%s is not a molecule (type: %s)", protoID, proto.Type)
}
// Validate molecule
if err := beads.ValidateMolecule(proto); err != nil {
return fmt.Errorf("invalid molecule: %w", err)
}
// Get the parent issue (the running molecule/wisp)
parent, err := b.Show(moleculeBondParent)
if err != nil {
return fmt.Errorf("getting parent: %w", err)
}
// Parse template variables from --var flags
ctx := make(map[string]string)
for _, kv := range moleculeBondVars {
parts := strings.SplitN(kv, "=", 2)
if len(parts) != 2 {
return fmt.Errorf("invalid var format %q (expected key=value)", kv)
}
ctx[parts[0]] = parts[1]
}
// Create the bonded child as an issue under the parent
// First, create a container issue for the bonded molecule
childTitle := proto.Title
if moleculeBondRef != "" {
childTitle = fmt.Sprintf("%s (%s)", proto.Title, moleculeBondRef)
}
// Expand template variables in the proto description
expandedDesc := beads.ExpandTemplateVars(proto.Description, ctx)
// Add bonding metadata
bondingMeta := fmt.Sprintf(`
---
bonded_from: %s
bonded_to: %s
bonded_ref: %s
bonded_at: %s
`, protoID, moleculeBondParent, moleculeBondRef, time.Now().UTC().Format(time.RFC3339))
childDesc := expandedDesc + bondingMeta
// Create the child molecule container
childOpts := beads.CreateOptions{
Title: childTitle,
Description: childDesc,
Type: "task", // Bonded children are tasks, not molecules
Priority: parent.Priority,
Parent: moleculeBondParent,
}
child, err := b.Create(childOpts)
if err != nil {
return fmt.Errorf("creating bonded child: %w", err)
}
// Now instantiate the proto's steps under this child
opts := beads.InstantiateOptions{Context: ctx}
steps, err := b.InstantiateMolecule(proto, child, opts)
if err != nil {
// Clean up the child container on failure (best-effort cleanup)
_ = b.Close(child.ID)
return fmt.Errorf("instantiating bonded molecule: %w", err)
}
if moleculeJSON {
result := map[string]interface{}{
"proto": protoID,
"parent": moleculeBondParent,
"ref": moleculeBondRef,
"child_id": child.ID,
"steps": len(steps),
"variables": ctx,
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(result)
}
fmt.Printf("%s Bonded %s to %s\n",
style.Bold.Render("🔗"), protoID, moleculeBondParent)
fmt.Printf(" Child: %s (%d steps)\n", child.ID, len(steps))
if moleculeBondRef != "" {
fmt.Printf(" Ref: %s\n", moleculeBondRef)
}
if len(ctx) > 0 {
fmt.Printf(" Variables: %v\n", ctx)
}
return nil
}
// runMoleculeCatalog lists available molecule protos.
func runMoleculeCatalog(cmd *cobra.Command, args []string) error {
workDir, err := findLocalBeadsDir()
if err != nil {
return fmt.Errorf("not in a beads workspace: %w", err)
}
// Load catalog
catalog, err := loadMoleculeCatalog(workDir)
if err != nil {
return fmt.Errorf("loading catalog: %w", err)
}
molecules := catalog.List()
if moleculeJSON {
type catalogEntry struct {
ID string `json:"id"`
Title string `json:"title"`
Source string `json:"source"`
StepCount int `json:"step_count"`
}
var entries []catalogEntry
for _, mol := range molecules {
steps, _ := beads.ParseMoleculeSteps(mol.Description)
entries = append(entries, catalogEntry{
ID: mol.ID,
Title: mol.Title,
Source: mol.Source,
StepCount: len(steps),
})
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(entries)
}
// Human-readable output
fmt.Printf("%s Molecule Catalog (%d protos)\n\n", style.Bold.Render("🧬"), len(molecules))
if len(molecules) == 0 {
fmt.Printf(" %s\n", style.Dim.Render("(no protos available)"))
return nil
}
for _, mol := range molecules {
steps, _ := beads.ParseMoleculeSteps(mol.Description)
stepCount := len(steps)
sourceMarker := style.Dim.Render(fmt.Sprintf("[%s]", mol.Source))
fmt.Printf(" %s: %s (%d steps) %s\n",
style.Bold.Render(mol.ID), mol.Title, stepCount, sourceMarker)
}
return nil
}
// runMoleculeBurn burns (destroys) the current molecule attachment.
func runMoleculeBurn(cmd *cobra.Command, args []string) error {
cwd, err := os.Getwd()
if err != nil {
return fmt.Errorf("getting current directory: %w", err)
}
// Find town root
townRoot, err := workspace.FindFromCwd()
if err != nil {
return fmt.Errorf("finding workspace: %w", err)
}
if townRoot == "" {
return fmt.Errorf("not in a Gas Town workspace")
}
// Determine target agent
var target string
if len(args) > 0 {
target = args[0]
} else {
// Auto-detect using env-aware role detection
roleInfo, err := GetRoleWithContext(cwd, townRoot)
if err != nil {
return fmt.Errorf("detecting role: %w", err)
}
roleCtx := RoleContext{
Role: roleInfo.Role,
Rig: roleInfo.Rig,
Polecat: roleInfo.Polecat,
TownRoot: townRoot,
WorkDir: cwd,
}
target = buildAgentIdentity(roleCtx)
if target == "" {
return fmt.Errorf("cannot determine agent identity (role: %s)", roleCtx.Role)
}
}
// Find beads directory
workDir, err := findLocalBeadsDir()
if err != nil {
return fmt.Errorf("not in a beads workspace: %w", err)
}
b := beads.New(workDir)
// Find agent's pinned bead (handoff bead)
parts := strings.Split(target, "/")
role := parts[len(parts)-1]
handoff, err := b.FindHandoffBead(role)
if err != nil {
return fmt.Errorf("finding handoff bead: %w", err)
}
if handoff == nil {
return fmt.Errorf("no handoff bead found for %s", target)
}
// Check for attached molecule
attachment := beads.ParseAttachmentFields(handoff)
if attachment == nil || attachment.AttachedMolecule == "" {
fmt.Printf("%s No molecule attached to %s - nothing to burn\n",
style.Dim.Render(""), target)
return nil
}
moleculeID := attachment.AttachedMolecule
// Recursively close all descendant step issues before detaching
// This prevents orphaned step issues from accumulating (gt-psj76.1)
childrenClosed := closeDescendants(b, moleculeID)
// Detach the molecule with audit logging (this "burns" it by removing the attachment)
_, err = b.DetachMoleculeWithAudit(handoff.ID, beads.DetachOptions{
Operation: "burn",
Agent: target,
Reason: "molecule burned by agent",
})
if err != nil {
return fmt.Errorf("detaching molecule: %w", err)
}
if moleculeJSON {
result := map[string]interface{}{
"burned": moleculeID,
"from": target,
"handoff_id": handoff.ID,
"children_closed": childrenClosed,
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(result)
}
fmt.Printf("%s Burned molecule %s from %s\n",
style.Bold.Render("🔥"), moleculeID, target)
if childrenClosed > 0 {
fmt.Printf(" Closed %d step issues\n", childrenClosed)
}
return nil
}
// runMoleculeSquash squashes the current molecule into a digest.
func runMoleculeSquash(cmd *cobra.Command, args []string) error {
cwd, err := os.Getwd()
if err != nil {
return fmt.Errorf("getting current directory: %w", err)
}
// Find town root
townRoot, err := workspace.FindFromCwd()
if err != nil {
return fmt.Errorf("finding workspace: %w", err)
}
if townRoot == "" {
return fmt.Errorf("not in a Gas Town workspace")
}
// Determine target agent
var target string
if len(args) > 0 {
target = args[0]
} else {
// Auto-detect using env-aware role detection
roleInfo, err := GetRoleWithContext(cwd, townRoot)
if err != nil {
return fmt.Errorf("detecting role: %w", err)
}
roleCtx := RoleContext{
Role: roleInfo.Role,
Rig: roleInfo.Rig,
Polecat: roleInfo.Polecat,
TownRoot: townRoot,
WorkDir: cwd,
}
target = buildAgentIdentity(roleCtx)
if target == "" {
return fmt.Errorf("cannot determine agent identity (role: %s)", roleCtx.Role)
}
}
// Find beads directory
workDir, err := findLocalBeadsDir()
if err != nil {
return fmt.Errorf("not in a beads workspace: %w", err)
}
b := beads.New(workDir)
// Find agent's pinned bead (handoff bead)
parts := strings.Split(target, "/")
role := parts[len(parts)-1]
handoff, err := b.FindHandoffBead(role)
if err != nil {
return fmt.Errorf("finding handoff bead: %w", err)
}
if handoff == nil {
return fmt.Errorf("no handoff bead found for %s", target)
}
// Check for attached molecule
attachment := beads.ParseAttachmentFields(handoff)
if attachment == nil || attachment.AttachedMolecule == "" {
fmt.Printf("%s No molecule attached to %s - nothing to squash\n",
style.Dim.Render(""), target)
return nil
}
moleculeID := attachment.AttachedMolecule
// Recursively close all descendant step issues before squashing
// This prevents orphaned step issues from accumulating (gt-psj76.1)
childrenClosed := closeDescendants(b, moleculeID)
// Get progress info for the digest
progress, _ := getMoleculeProgressInfo(b, moleculeID)
// Create a digest issue
digestTitle := fmt.Sprintf("Digest: %s", moleculeID)
digestDesc := fmt.Sprintf(`Squashed molecule execution.
molecule: %s
agent: %s
squashed_at: %s
`, moleculeID, target, time.Now().UTC().Format(time.RFC3339))
if progress != nil {
digestDesc += fmt.Sprintf(`
## Execution Summary
- Steps: %d/%d completed
- Status: %s
`, progress.DoneSteps, progress.TotalSteps, func() string {
if progress.Complete {
return "complete"
}
return "partial"
}())
}
// Create the digest bead
digestIssue, err := b.Create(beads.CreateOptions{
Title: digestTitle,
Description: digestDesc,
Type: "task",
Priority: 4, // P4 - backlog priority for digests
Actor: target,
})
if err != nil {
return fmt.Errorf("creating digest: %w", err)
}
// Add the digest label (non-fatal: digest works without label)
_ = b.Update(digestIssue.ID, beads.UpdateOptions{
AddLabels: []string{"digest"},
})
// Close the digest immediately
closedStatus := "closed"
err = b.Update(digestIssue.ID, beads.UpdateOptions{
Status: &closedStatus,
})
if err != nil {
fmt.Printf("%s Created digest but couldn't close it: %v\n",
style.Dim.Render("Warning:"), err)
}
// Detach the molecule from the handoff bead with audit logging
_, err = b.DetachMoleculeWithAudit(handoff.ID, beads.DetachOptions{
Operation: "squash",
Agent: target,
Reason: fmt.Sprintf("molecule squashed to digest %s", digestIssue.ID),
})
if err != nil {
return fmt.Errorf("detaching molecule: %w", err)
}
if moleculeJSON {
result := map[string]interface{}{
"squashed": moleculeID,
"digest_id": digestIssue.ID,
"from": target,
"handoff_id": handoff.ID,
"children_closed": childrenClosed,
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(result)
}
fmt.Printf("%s Squashed molecule %s → digest %s\n",
style.Bold.Render("📦"), moleculeID, digestIssue.ID)
if childrenClosed > 0 {
fmt.Printf(" Closed %d step issues\n", childrenClosed)
}
return nil
}
// closeDescendants recursively closes all descendant issues of a parent.
// Returns the count of issues closed. Logs warnings on errors but doesn't fail.
func closeDescendants(b *beads.Beads, parentID string) int {
children, err := b.List(beads.ListOptions{
Parent: parentID,
Status: "all",
})
if err != nil {
fmt.Printf("%s Warning: could not list children of %s: %v\n",
style.Dim.Render("⚠"), parentID, err)
return 0
}
if len(children) == 0 {
return 0
}
// First, recursively close grandchildren
totalClosed := 0
for _, child := range children {
totalClosed += closeDescendants(b, child.ID)
}
// Then close direct children
var idsToClose []string
for _, child := range children {
if child.Status != "closed" {
idsToClose = append(idsToClose, child.ID)
}
}
if len(idsToClose) > 0 {
if closeErr := b.Close(idsToClose...); closeErr != nil {
fmt.Printf("%s Warning: could not close children of %s: %v\n",
style.Dim.Render("⚠"), parentID, closeErr)
} else {
totalClosed += len(idsToClose)
}
}
return totalClosed
}