fix(sling): auto-apply mol-polecat-work (#288) and fix wisp orphan lifecycle bug (#842) Fixes the formula-on-bead pattern to hook the base bead instead of the wisp: - Auto-apply mol-polecat-work when slinging bare beads to polecats - Hook BASE bead with attached_molecule pointing to wisp - gt done now closes attached molecule before closing hooked bead - Convoys complete properly when work finishes Fixes #288, #842, #858
This commit is contained in:
@@ -158,12 +158,9 @@ func (b *Beads) AttachMolecule(pinnedBeadID, moleculeID string) (*Issue, error)
|
|||||||
return nil, fmt.Errorf("fetching pinned bead: %w", err)
|
return nil, fmt.Errorf("fetching pinned bead: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Allow pinned beads OR open polecat agent beads (polecats have a lifecycle, not permanent)
|
// Only allow pinned beads (permanent records like role definitions)
|
||||||
if issue.Status != StatusPinned {
|
if issue.Status != StatusPinned {
|
||||||
_, role, _, ok := ParseAgentBeadID(pinnedBeadID)
|
return nil, fmt.Errorf("issue %s is not pinned (status: %s)", pinnedBeadID, issue.Status)
|
||||||
if !(issue.Status == "open" && ok && role == "polecat") {
|
|
||||||
return nil, fmt.Errorf("issue %s is not pinned or open polecat (status: %s)", pinnedBeadID, issue.Status)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build attachment fields with current timestamp
|
// Build attachment fields with current timestamp
|
||||||
|
|||||||
@@ -603,6 +603,19 @@ func updateAgentStateOnDone(cwd, townRoot, exitType, _ string) { // issueID unus
|
|||||||
hookedBeadID := agentBead.HookBead
|
hookedBeadID := agentBead.HookBead
|
||||||
// Only close if the hooked bead exists and is still in "hooked" status
|
// Only close if the hooked bead exists and is still in "hooked" status
|
||||||
if hookedBead, err := bd.Show(hookedBeadID); err == nil && hookedBead.Status == beads.StatusHooked {
|
if hookedBead, err := bd.Show(hookedBeadID); err == nil && hookedBead.Status == beads.StatusHooked {
|
||||||
|
// BUG FIX: Close attached molecule (wisp) BEFORE closing hooked bead.
|
||||||
|
// When using formula-on-bead (gt sling formula --on bead), the base bead
|
||||||
|
// has attached_molecule pointing to the wisp. Without this fix, gt done
|
||||||
|
// only closed the hooked bead, leaving the wisp orphaned.
|
||||||
|
// Order matters: wisp closes -> unblocks base bead -> base bead closes.
|
||||||
|
attachment := beads.ParseAttachmentFields(hookedBead)
|
||||||
|
if attachment != nil && attachment.AttachedMolecule != "" {
|
||||||
|
if err := bd.Close(attachment.AttachedMolecule); err != nil {
|
||||||
|
// Non-fatal: warn but continue
|
||||||
|
fmt.Fprintf(os.Stderr, "Warning: couldn't close attached molecule %s: %v\n", attachment.AttachedMolecule, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err := bd.Close(hookedBeadID); err != nil {
|
if err := bd.Close(hookedBeadID); err != nil {
|
||||||
// Non-fatal: warn but continue
|
// Non-fatal: warn but continue
|
||||||
fmt.Fprintf(os.Stderr, "Warning: couldn't close hooked bead %s: %v\n", hookedBeadID, err)
|
fmt.Fprintf(os.Stderr, "Warning: couldn't close hooked bead %s: %v\n", hookedBeadID, err)
|
||||||
|
|||||||
476
internal/cmd/molecule_lifecycle_test.go
Normal file
476
internal/cmd/molecule_lifecycle_test.go
Normal file
@@ -0,0 +1,476 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestSlingFormulaOnBeadHooksBaseBead verifies that when using
|
||||||
|
// "gt sling <formula> --on <bead>", the BASE bead is hooked (not the wisp).
|
||||||
|
//
|
||||||
|
// Current bug: The code hooks the wisp (compound root) instead of the base bead.
|
||||||
|
// This causes lifecycle issues:
|
||||||
|
// - Base bead stays open after wisp completes
|
||||||
|
// - gt done closes wisp, not the actual work item
|
||||||
|
// - Orphaned base beads accumulate
|
||||||
|
//
|
||||||
|
// Expected behavior: Hook the base bead, store attached_molecule pointing to wisp.
|
||||||
|
// gt hook/gt prime can follow attached_molecule to find the workflow steps.
|
||||||
|
func TestSlingFormulaOnBeadHooksBaseBead(t *testing.T) {
|
||||||
|
townRoot := t.TempDir()
|
||||||
|
|
||||||
|
// Minimal workspace marker
|
||||||
|
if err := os.MkdirAll(filepath.Join(townRoot, "mayor", "rig"), 0755); err != nil {
|
||||||
|
t.Fatalf("mkdir mayor/rig: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create routes
|
||||||
|
if err := os.MkdirAll(filepath.Join(townRoot, ".beads"), 0755); err != nil {
|
||||||
|
t.Fatalf("mkdir .beads: %v", err)
|
||||||
|
}
|
||||||
|
rigDir := filepath.Join(townRoot, "gastown", "mayor", "rig")
|
||||||
|
if err := os.MkdirAll(rigDir, 0755); err != nil {
|
||||||
|
t.Fatalf("mkdir rigDir: %v", err)
|
||||||
|
}
|
||||||
|
routes := strings.Join([]string{
|
||||||
|
`{"prefix":"gt-","path":"gastown/mayor/rig"}`,
|
||||||
|
`{"prefix":"hq-","path":"."}`,
|
||||||
|
"",
|
||||||
|
}, "\n")
|
||||||
|
if err := os.WriteFile(filepath.Join(townRoot, ".beads", "routes.jsonl"), []byte(routes), 0644); err != nil {
|
||||||
|
t.Fatalf("write routes.jsonl: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stub bd to track which bead gets hooked
|
||||||
|
binDir := filepath.Join(townRoot, "bin")
|
||||||
|
if err := os.MkdirAll(binDir, 0755); err != nil {
|
||||||
|
t.Fatalf("mkdir binDir: %v", err)
|
||||||
|
}
|
||||||
|
logPath := filepath.Join(townRoot, "bd.log")
|
||||||
|
bdScript := `#!/bin/sh
|
||||||
|
set -e
|
||||||
|
echo "$*" >> "${BD_LOG}"
|
||||||
|
if [ "$1" = "--no-daemon" ]; then
|
||||||
|
shift
|
||||||
|
fi
|
||||||
|
if [ "$1" = "--allow-stale" ]; then
|
||||||
|
shift
|
||||||
|
fi
|
||||||
|
cmd="$1"
|
||||||
|
shift || true
|
||||||
|
case "$cmd" in
|
||||||
|
show)
|
||||||
|
# Return the base bead info
|
||||||
|
echo '[{"id":"gt-abc123","title":"Bug to fix","status":"open","assignee":"","description":""}]'
|
||||||
|
;;
|
||||||
|
formula)
|
||||||
|
echo '{"name":"mol-polecat-work"}'
|
||||||
|
;;
|
||||||
|
cook)
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
mol)
|
||||||
|
sub="$1"
|
||||||
|
shift || true
|
||||||
|
case "$sub" in
|
||||||
|
wisp)
|
||||||
|
echo '{"new_epic_id":"gt-wisp-xyz"}'
|
||||||
|
;;
|
||||||
|
bond)
|
||||||
|
echo '{"root_id":"gt-wisp-xyz"}'
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
;;
|
||||||
|
update)
|
||||||
|
# Just succeed
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
exit 0
|
||||||
|
`
|
||||||
|
bdPath := filepath.Join(binDir, "bd")
|
||||||
|
if err := os.WriteFile(bdPath, []byte(bdScript), 0755); err != nil {
|
||||||
|
t.Fatalf("write bd stub: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Setenv("BD_LOG", logPath)
|
||||||
|
t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||||
|
t.Setenv(EnvGTRole, "mayor")
|
||||||
|
t.Setenv("GT_POLECAT", "")
|
||||||
|
t.Setenv("GT_CREW", "")
|
||||||
|
t.Setenv("TMUX_PANE", "")
|
||||||
|
t.Setenv("GT_TEST_NO_NUDGE", "1")
|
||||||
|
|
||||||
|
cwd, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("getwd: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = os.Chdir(cwd) })
|
||||||
|
if err := os.Chdir(filepath.Join(townRoot, "mayor", "rig")); err != nil {
|
||||||
|
t.Fatalf("chdir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save and restore global flag state
|
||||||
|
prevOn := slingOnTarget
|
||||||
|
prevVars := slingVars
|
||||||
|
prevDryRun := slingDryRun
|
||||||
|
prevNoConvoy := slingNoConvoy
|
||||||
|
t.Cleanup(func() {
|
||||||
|
slingOnTarget = prevOn
|
||||||
|
slingVars = prevVars
|
||||||
|
slingDryRun = prevDryRun
|
||||||
|
slingNoConvoy = prevNoConvoy
|
||||||
|
})
|
||||||
|
|
||||||
|
slingDryRun = false
|
||||||
|
slingNoConvoy = true
|
||||||
|
slingVars = nil
|
||||||
|
slingOnTarget = "gt-abc123" // The base bead
|
||||||
|
|
||||||
|
if err := runSling(nil, []string{"mol-polecat-work"}); err != nil {
|
||||||
|
t.Fatalf("runSling: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logBytes, err := os.ReadFile(logPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read bd log: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the update command that sets status=hooked
|
||||||
|
// Expected: should hook gt-abc123 (base bead)
|
||||||
|
// Current bug: hooks gt-wisp-xyz (wisp)
|
||||||
|
logLines := strings.Split(string(logBytes), "\n")
|
||||||
|
var hookedBeadID string
|
||||||
|
for _, line := range logLines {
|
||||||
|
if strings.Contains(line, "update") && strings.Contains(line, "--status=hooked") {
|
||||||
|
// Extract the bead ID being hooked
|
||||||
|
// Format: "update <beadID> --status=hooked ..."
|
||||||
|
parts := strings.Fields(line)
|
||||||
|
for i, part := range parts {
|
||||||
|
if part == "update" && i+1 < len(parts) {
|
||||||
|
hookedBeadID = parts[i+1]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if hookedBeadID == "" {
|
||||||
|
t.Fatalf("no hooked bead found in log:\n%s", string(logBytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
// The BASE bead (gt-abc123) should be hooked, not the wisp (gt-wisp-xyz)
|
||||||
|
if hookedBeadID != "gt-abc123" {
|
||||||
|
t.Errorf("wrong bead hooked: got %q, want %q (base bead)\n"+
|
||||||
|
"Current behavior hooks the wisp instead of the base bead.\n"+
|
||||||
|
"This causes orphaned base beads when gt done closes only the wisp.\n"+
|
||||||
|
"Log:\n%s", hookedBeadID, "gt-abc123", string(logBytes))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSlingFormulaOnBeadSetsAttachedMoleculeInBaseBead verifies that when using
|
||||||
|
// "gt sling <formula> --on <bead>", the attached_molecule field is set in the
|
||||||
|
// BASE bead's description (pointing to the wisp), not in the wisp itself.
|
||||||
|
//
|
||||||
|
// Current bug: attached_molecule is stored as a self-reference in the wisp.
|
||||||
|
// This is semantically meaningless (wisp points to itself) and breaks
|
||||||
|
// compound resolution from the base bead.
|
||||||
|
//
|
||||||
|
// Expected behavior: Store attached_molecule in the base bead pointing to wisp.
|
||||||
|
// This enables:
|
||||||
|
// - Compound resolution: base bead -> attached_molecule -> wisp
|
||||||
|
// - gt hook/gt prime: read base bead, follow attached_molecule to show wisp steps
|
||||||
|
func TestSlingFormulaOnBeadSetsAttachedMoleculeInBaseBead(t *testing.T) {
|
||||||
|
townRoot := t.TempDir()
|
||||||
|
|
||||||
|
// Minimal workspace marker
|
||||||
|
if err := os.MkdirAll(filepath.Join(townRoot, "mayor", "rig"), 0755); err != nil {
|
||||||
|
t.Fatalf("mkdir mayor/rig: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create routes
|
||||||
|
if err := os.MkdirAll(filepath.Join(townRoot, ".beads"), 0755); err != nil {
|
||||||
|
t.Fatalf("mkdir .beads: %v", err)
|
||||||
|
}
|
||||||
|
rigDir := filepath.Join(townRoot, "gastown", "mayor", "rig")
|
||||||
|
if err := os.MkdirAll(rigDir, 0755); err != nil {
|
||||||
|
t.Fatalf("mkdir rigDir: %v", err)
|
||||||
|
}
|
||||||
|
routes := strings.Join([]string{
|
||||||
|
`{"prefix":"gt-","path":"gastown/mayor/rig"}`,
|
||||||
|
`{"prefix":"hq-","path":"."}`,
|
||||||
|
"",
|
||||||
|
}, "\n")
|
||||||
|
if err := os.WriteFile(filepath.Join(townRoot, ".beads", "routes.jsonl"), []byte(routes), 0644); err != nil {
|
||||||
|
t.Fatalf("write routes.jsonl: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stub bd to track which bead gets attached_molecule set
|
||||||
|
binDir := filepath.Join(townRoot, "bin")
|
||||||
|
if err := os.MkdirAll(binDir, 0755); err != nil {
|
||||||
|
t.Fatalf("mkdir binDir: %v", err)
|
||||||
|
}
|
||||||
|
logPath := filepath.Join(townRoot, "bd.log")
|
||||||
|
bdScript := `#!/bin/sh
|
||||||
|
set -e
|
||||||
|
echo "$*" >> "${BD_LOG}"
|
||||||
|
if [ "$1" = "--no-daemon" ]; then
|
||||||
|
shift
|
||||||
|
fi
|
||||||
|
if [ "$1" = "--allow-stale" ]; then
|
||||||
|
shift
|
||||||
|
fi
|
||||||
|
cmd="$1"
|
||||||
|
shift || true
|
||||||
|
case "$cmd" in
|
||||||
|
show)
|
||||||
|
# Return bead info without attached_molecule initially
|
||||||
|
echo '[{"id":"gt-abc123","title":"Bug to fix","status":"open","assignee":"","description":""}]'
|
||||||
|
;;
|
||||||
|
formula)
|
||||||
|
echo '{"name":"mol-polecat-work"}'
|
||||||
|
;;
|
||||||
|
cook)
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
mol)
|
||||||
|
sub="$1"
|
||||||
|
shift || true
|
||||||
|
case "$sub" in
|
||||||
|
wisp)
|
||||||
|
echo '{"new_epic_id":"gt-wisp-xyz"}'
|
||||||
|
;;
|
||||||
|
bond)
|
||||||
|
echo '{"root_id":"gt-wisp-xyz"}'
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
;;
|
||||||
|
update)
|
||||||
|
# Just succeed
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
exit 0
|
||||||
|
`
|
||||||
|
bdPath := filepath.Join(binDir, "bd")
|
||||||
|
if err := os.WriteFile(bdPath, []byte(bdScript), 0755); err != nil {
|
||||||
|
t.Fatalf("write bd stub: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Setenv("BD_LOG", logPath)
|
||||||
|
t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||||
|
t.Setenv(EnvGTRole, "mayor")
|
||||||
|
t.Setenv("GT_POLECAT", "")
|
||||||
|
t.Setenv("GT_CREW", "")
|
||||||
|
t.Setenv("TMUX_PANE", "")
|
||||||
|
t.Setenv("GT_TEST_NO_NUDGE", "1")
|
||||||
|
|
||||||
|
cwd, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("getwd: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = os.Chdir(cwd) })
|
||||||
|
if err := os.Chdir(filepath.Join(townRoot, "mayor", "rig")); err != nil {
|
||||||
|
t.Fatalf("chdir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save and restore global flag state
|
||||||
|
prevOn := slingOnTarget
|
||||||
|
prevVars := slingVars
|
||||||
|
prevDryRun := slingDryRun
|
||||||
|
prevNoConvoy := slingNoConvoy
|
||||||
|
t.Cleanup(func() {
|
||||||
|
slingOnTarget = prevOn
|
||||||
|
slingVars = prevVars
|
||||||
|
slingDryRun = prevDryRun
|
||||||
|
slingNoConvoy = prevNoConvoy
|
||||||
|
})
|
||||||
|
|
||||||
|
slingDryRun = false
|
||||||
|
slingNoConvoy = true
|
||||||
|
slingVars = nil
|
||||||
|
slingOnTarget = "gt-abc123" // The base bead
|
||||||
|
|
||||||
|
if err := runSling(nil, []string{"mol-polecat-work"}); err != nil {
|
||||||
|
t.Fatalf("runSling: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logBytes, err := os.ReadFile(logPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read bd log: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find update commands that set attached_molecule
|
||||||
|
// Expected: "update gt-abc123 --description=...attached_molecule: gt-wisp-xyz..."
|
||||||
|
// Current bug: "update gt-wisp-xyz --description=...attached_molecule: gt-wisp-xyz..."
|
||||||
|
logLines := strings.Split(string(logBytes), "\n")
|
||||||
|
var attachedMoleculeTarget string
|
||||||
|
for _, line := range logLines {
|
||||||
|
if strings.Contains(line, "update") && strings.Contains(line, "attached_molecule") {
|
||||||
|
// Extract the bead ID being updated
|
||||||
|
parts := strings.Fields(line)
|
||||||
|
for i, part := range parts {
|
||||||
|
if part == "update" && i+1 < len(parts) {
|
||||||
|
attachedMoleculeTarget = parts[i+1]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if attachedMoleculeTarget == "" {
|
||||||
|
t.Fatalf("no attached_molecule update found in log:\n%s", string(logBytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
// attached_molecule should be set on the BASE bead, not the wisp
|
||||||
|
if attachedMoleculeTarget != "gt-abc123" {
|
||||||
|
t.Errorf("attached_molecule set on wrong bead: got %q, want %q (base bead)\n"+
|
||||||
|
"Current behavior stores attached_molecule in the wisp as a self-reference.\n"+
|
||||||
|
"This breaks compound resolution (base bead has no pointer to wisp).\n"+
|
||||||
|
"Log:\n%s", attachedMoleculeTarget, "gt-abc123", string(logBytes))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDoneClosesAttachedMolecule verifies that gt done closes both the hooked
|
||||||
|
// bead AND its attached molecule (wisp).
|
||||||
|
//
|
||||||
|
// Current bug: gt done only closes the hooked bead. If base bead is hooked
|
||||||
|
// with attached_molecule pointing to wisp, the wisp becomes orphaned.
|
||||||
|
//
|
||||||
|
// Expected behavior: gt done should:
|
||||||
|
// 1. Check for attached_molecule in hooked bead
|
||||||
|
// 2. Close the attached molecule (wisp) first
|
||||||
|
// 3. Close the hooked bead (base bead)
|
||||||
|
//
|
||||||
|
// This ensures no orphaned wisps remain after work completes.
|
||||||
|
func TestDoneClosesAttachedMolecule(t *testing.T) {
|
||||||
|
townRoot := t.TempDir()
|
||||||
|
|
||||||
|
// Create rig structure - use simple rig name that matches routes lookup
|
||||||
|
rigPath := filepath.Join(townRoot, "gastown")
|
||||||
|
if err := os.MkdirAll(rigPath, 0755); err != nil {
|
||||||
|
t.Fatalf("mkdir rig: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Join(townRoot, ".beads"), 0755); err != nil {
|
||||||
|
t.Fatalf("mkdir .beads: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create routes - path first part must match GT_RIG for prefix lookup
|
||||||
|
routes := strings.Join([]string{
|
||||||
|
`{"prefix":"gt-","path":"gastown"}`,
|
||||||
|
"",
|
||||||
|
}, "\n")
|
||||||
|
if err := os.WriteFile(filepath.Join(townRoot, ".beads", "routes.jsonl"), []byte(routes), 0644); err != nil {
|
||||||
|
t.Fatalf("write routes.jsonl: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stub bd to track close calls
|
||||||
|
binDir := filepath.Join(townRoot, "bin")
|
||||||
|
if err := os.MkdirAll(binDir, 0755); err != nil {
|
||||||
|
t.Fatalf("mkdir binDir: %v", err)
|
||||||
|
}
|
||||||
|
closesPath := filepath.Join(townRoot, "closes.log")
|
||||||
|
|
||||||
|
// The stub simulates:
|
||||||
|
// - Agent bead gt-agent-nux with hook_bead = gt-abc123 (base bead)
|
||||||
|
// - Base bead gt-abc123 with attached_molecule: gt-wisp-xyz, status=hooked
|
||||||
|
// - Wisp gt-wisp-xyz (the attached molecule)
|
||||||
|
bdScript := fmt.Sprintf(`#!/bin/sh
|
||||||
|
echo "$*" >> "%s/bd.log"
|
||||||
|
# Strip --no-daemon and --allow-stale
|
||||||
|
while [ "$1" = "--no-daemon" ] || [ "$1" = "--allow-stale" ]; do
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
cmd="$1"
|
||||||
|
shift || true
|
||||||
|
case "$cmd" in
|
||||||
|
show)
|
||||||
|
beadID="$1"
|
||||||
|
case "$beadID" in
|
||||||
|
gt-gastown-polecat-nux)
|
||||||
|
echo '[{"id":"gt-gastown-polecat-nux","title":"Polecat nux","status":"open","hook_bead":"gt-abc123","agent_state":"working"}]'
|
||||||
|
;;
|
||||||
|
gt-abc123)
|
||||||
|
echo '[{"id":"gt-abc123","title":"Bug to fix","status":"hooked","description":"attached_molecule: gt-wisp-xyz"}]'
|
||||||
|
;;
|
||||||
|
gt-wisp-xyz)
|
||||||
|
echo '[{"id":"gt-wisp-xyz","title":"mol-polecat-work","status":"open","ephemeral":true}]'
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo '[]'
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
;;
|
||||||
|
close)
|
||||||
|
echo "$1" >> "%s"
|
||||||
|
;;
|
||||||
|
agent|update|slot)
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
exit 0
|
||||||
|
`, townRoot, closesPath)
|
||||||
|
|
||||||
|
bdPath := filepath.Join(binDir, "bd")
|
||||||
|
if err := os.WriteFile(bdPath, []byte(bdScript), 0755); err != nil {
|
||||||
|
t.Fatalf("write bd stub: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||||
|
t.Setenv("GT_ROLE", "polecat")
|
||||||
|
t.Setenv("GT_RIG", "gastown")
|
||||||
|
t.Setenv("GT_POLECAT", "nux")
|
||||||
|
t.Setenv("GT_CREW", "")
|
||||||
|
t.Setenv("TMUX_PANE", "")
|
||||||
|
|
||||||
|
cwd, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("getwd: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = os.Chdir(cwd) })
|
||||||
|
if err := os.Chdir(rigPath); err != nil {
|
||||||
|
t.Fatalf("chdir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call the unexported function directly (same package)
|
||||||
|
// updateAgentStateOnDone(cwd, townRoot, exitType, issueID)
|
||||||
|
updateAgentStateOnDone(rigPath, townRoot, ExitCompleted, "")
|
||||||
|
|
||||||
|
// Read the close log to see what got closed
|
||||||
|
closesBytes, err := os.ReadFile(closesPath)
|
||||||
|
if err != nil {
|
||||||
|
// No closes happened at all - that's a failure
|
||||||
|
t.Fatalf("no beads were closed (closes.log doesn't exist)")
|
||||||
|
}
|
||||||
|
closes := string(closesBytes)
|
||||||
|
closeLines := strings.Split(strings.TrimSpace(closes), "\n")
|
||||||
|
|
||||||
|
// Check that attached molecule gt-wisp-xyz was closed
|
||||||
|
foundWisp := false
|
||||||
|
foundBase := false
|
||||||
|
for _, line := range closeLines {
|
||||||
|
if strings.Contains(line, "gt-wisp-xyz") {
|
||||||
|
foundWisp = true
|
||||||
|
}
|
||||||
|
if strings.Contains(line, "gt-abc123") {
|
||||||
|
foundBase = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !foundWisp {
|
||||||
|
t.Errorf("attached molecule gt-wisp-xyz was NOT closed\n"+
|
||||||
|
"gt done should close the attached_molecule before closing the hooked bead.\n"+
|
||||||
|
"This leaves orphaned wisps after work completes.\n"+
|
||||||
|
"Beads closed: %v", closeLines)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !foundBase {
|
||||||
|
t.Errorf("hooked bead gt-abc123 was NOT closed\n"+
|
||||||
|
"Beads closed: %v", closeLines)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -501,6 +501,23 @@ func checkSlungWork(ctx RoleContext) bool {
|
|||||||
}
|
}
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
|
|
||||||
|
// Check for attached molecule and show execution prompt
|
||||||
|
// This was missing for hooked beads (only worked for pinned beads).
|
||||||
|
// With formula-on-bead, the base bead is hooked with attached_molecule pointing to wisp.
|
||||||
|
attachment := beads.ParseAttachmentFields(hookedBead)
|
||||||
|
if attachment != nil && attachment.AttachedMolecule != "" {
|
||||||
|
fmt.Printf("%s\n\n", style.Bold.Render("## 🎯 ATTACHED MOLECULE"))
|
||||||
|
fmt.Printf("Molecule: %s\n", attachment.AttachedMolecule)
|
||||||
|
if attachment.AttachedArgs != "" {
|
||||||
|
fmt.Printf("\n%s\n", style.Bold.Render("📋 ARGS (use these to guide execution):"))
|
||||||
|
fmt.Printf(" %s\n", attachment.AttachedArgs)
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
// Show current step from molecule
|
||||||
|
showMoleculeExecutionPrompt(ctx.WorkDir, attachment.AttachedMolecule)
|
||||||
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
@@ -83,12 +82,13 @@ Batch Slinging:
|
|||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
slingSubject string
|
slingSubject string
|
||||||
slingMessage string
|
slingMessage string
|
||||||
slingDryRun bool
|
slingDryRun bool
|
||||||
slingOnTarget string // --on flag: target bead when slinging a formula
|
slingOnTarget string // --on flag: target bead when slinging a formula
|
||||||
slingVars []string // --var flag: formula variables (key=value)
|
slingVars []string // --var flag: formula variables (key=value)
|
||||||
slingArgs string // --args flag: natural language instructions for executor
|
slingArgs string // --args flag: natural language instructions for executor
|
||||||
|
slingHookRawBead bool // --hook-raw-bead: hook raw bead without default formula (expert mode)
|
||||||
|
|
||||||
// Flags migrated for polecat spawning (used by sling for work assignment)
|
// Flags migrated for polecat spawning (used by sling for work assignment)
|
||||||
slingCreate bool // --create: create polecat if it doesn't exist
|
slingCreate bool // --create: create polecat if it doesn't exist
|
||||||
@@ -112,6 +112,7 @@ func init() {
|
|||||||
slingCmd.Flags().StringVar(&slingAccount, "account", "", "Claude Code account handle to use")
|
slingCmd.Flags().StringVar(&slingAccount, "account", "", "Claude Code account handle to use")
|
||||||
slingCmd.Flags().StringVar(&slingAgent, "agent", "", "Override agent/runtime for this sling (e.g., claude, gemini, codex, or custom alias)")
|
slingCmd.Flags().StringVar(&slingAgent, "agent", "", "Override agent/runtime for this sling (e.g., claude, gemini, codex, or custom alias)")
|
||||||
slingCmd.Flags().BoolVar(&slingNoConvoy, "no-convoy", false, "Skip auto-convoy creation for single-issue sling")
|
slingCmd.Flags().BoolVar(&slingNoConvoy, "no-convoy", false, "Skip auto-convoy creation for single-issue sling")
|
||||||
|
slingCmd.Flags().BoolVar(&slingHookRawBead, "hook-raw-bead", false, "Hook raw bead without default formula (expert mode)")
|
||||||
|
|
||||||
rootCmd.AddCommand(slingCmd)
|
rootCmd.AddCommand(slingCmd)
|
||||||
}
|
}
|
||||||
@@ -398,6 +399,14 @@ func runSling(cmd *cobra.Command, args []string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Issue #288: Auto-apply mol-polecat-work when slinging bare bead to polecat.
|
||||||
|
// This ensures polecats get structured work guidance through formula-on-bead.
|
||||||
|
// Use --hook-raw-bead to bypass for expert/debugging scenarios.
|
||||||
|
if formulaName == "" && !slingHookRawBead && strings.Contains(targetAgent, "/polecats/") {
|
||||||
|
formulaName = "mol-polecat-work"
|
||||||
|
fmt.Printf(" Auto-applying %s for polecat work...\n", formulaName)
|
||||||
|
}
|
||||||
|
|
||||||
if slingDryRun {
|
if slingDryRun {
|
||||||
if formulaName != "" {
|
if formulaName != "" {
|
||||||
fmt.Printf("Would instantiate formula %s:\n", formulaName)
|
fmt.Printf("Would instantiate formula %s:\n", formulaName)
|
||||||
@@ -425,71 +434,30 @@ func runSling(cmd *cobra.Command, args []string) error {
|
|||||||
if formulaName != "" {
|
if formulaName != "" {
|
||||||
fmt.Printf(" Instantiating formula %s...\n", formulaName)
|
fmt.Printf(" Instantiating formula %s...\n", formulaName)
|
||||||
|
|
||||||
// Route bd mutations (wisp/bond) to the correct beads context for the target bead.
|
result, err := InstantiateFormulaOnBead(formulaName, beadID, info.Title, hookWorkDir, townRoot, false)
|
||||||
// Some bd mol commands don't support prefix routing, so we must run them from the
|
|
||||||
// rig directory that owns the bead's database.
|
|
||||||
formulaWorkDir := beads.ResolveHookDir(townRoot, beadID, hookWorkDir)
|
|
||||||
|
|
||||||
// Step 1: Cook the formula (ensures proto exists)
|
|
||||||
// Cook runs from rig directory to access the correct formula database
|
|
||||||
cookCmd := exec.Command("bd", "--no-daemon", "cook", formulaName)
|
|
||||||
cookCmd.Dir = formulaWorkDir
|
|
||||||
cookCmd.Stderr = os.Stderr
|
|
||||||
if err := cookCmd.Run(); err != nil {
|
|
||||||
return fmt.Errorf("cooking formula %s: %w", formulaName, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 2: Create wisp with feature and issue variables from bead
|
|
||||||
// Run from rig directory so wisp is created in correct database
|
|
||||||
featureVar := fmt.Sprintf("feature=%s", info.Title)
|
|
||||||
issueVar := fmt.Sprintf("issue=%s", beadID)
|
|
||||||
wispArgs := []string{"--no-daemon", "mol", "wisp", formulaName, "--var", featureVar, "--var", issueVar, "--json"}
|
|
||||||
wispCmd := exec.Command("bd", wispArgs...)
|
|
||||||
wispCmd.Dir = formulaWorkDir
|
|
||||||
wispCmd.Env = append(os.Environ(), "GT_ROOT="+townRoot)
|
|
||||||
wispCmd.Stderr = os.Stderr
|
|
||||||
wispOut, err := wispCmd.Output()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("creating wisp for formula %s: %w", formulaName, err)
|
return fmt.Errorf("instantiating formula %s: %w", formulaName, err)
|
||||||
}
|
|
||||||
|
|
||||||
// Parse wisp output to get the root ID
|
|
||||||
wispRootID, err := parseWispIDFromJSON(wispOut)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("parsing wisp output: %w", err)
|
|
||||||
}
|
|
||||||
fmt.Printf("%s Formula wisp created: %s\n", style.Bold.Render("✓"), wispRootID)
|
|
||||||
|
|
||||||
// Step 3: Bond wisp to original bead (creates compound)
|
|
||||||
// Use --no-daemon for mol bond (requires direct database access)
|
|
||||||
bondArgs := []string{"--no-daemon", "mol", "bond", wispRootID, beadID, "--json"}
|
|
||||||
bondCmd := exec.Command("bd", bondArgs...)
|
|
||||||
bondCmd.Dir = formulaWorkDir
|
|
||||||
bondCmd.Stderr = os.Stderr
|
|
||||||
bondOut, err := bondCmd.Output()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("bonding formula to bead: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse bond output - the wisp root becomes the compound root
|
|
||||||
// After bonding, we hook the wisp root (which now contains the original bead)
|
|
||||||
var bondResult struct {
|
|
||||||
RootID string `json:"root_id"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(bondOut, &bondResult); err != nil {
|
|
||||||
// Fallback: use wisp root as the compound root
|
|
||||||
fmt.Printf("%s Could not parse bond output, using wisp root\n", style.Dim.Render("Warning:"))
|
|
||||||
} else if bondResult.RootID != "" {
|
|
||||||
wispRootID = bondResult.RootID
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fmt.Printf("%s Formula wisp created: %s\n", style.Bold.Render("✓"), result.WispRootID)
|
||||||
fmt.Printf("%s Formula bonded to %s\n", style.Bold.Render("✓"), beadID)
|
fmt.Printf("%s Formula bonded to %s\n", style.Bold.Render("✓"), beadID)
|
||||||
|
|
||||||
// Record attached molecule after other description updates to avoid overwrite.
|
// Record attached molecule - will be stored in BASE bead (not wisp).
|
||||||
attachedMoleculeID = wispRootID
|
// The base bead is hooked, and its attached_molecule points to the wisp.
|
||||||
|
// This enables:
|
||||||
|
// - gt hook/gt prime: read base bead, follow attached_molecule to show wisp steps
|
||||||
|
// - gt done: close attached_molecule (wisp) first, then close base bead
|
||||||
|
// - Compound resolution: base bead -> attached_molecule -> wisp
|
||||||
|
attachedMoleculeID = result.WispRootID
|
||||||
|
|
||||||
// Update beadID to hook the compound root instead of bare bead
|
// NOTE: We intentionally keep beadID as the ORIGINAL base bead, not the wisp.
|
||||||
beadID = wispRootID
|
// The base bead is hooked so that:
|
||||||
|
// 1. gt done closes both the base bead AND the attached molecule (wisp)
|
||||||
|
// 2. The base bead's attached_molecule field points to the wisp for compound resolution
|
||||||
|
// Previously, this line incorrectly set beadID = wispRootID, causing:
|
||||||
|
// - Wisp hooked instead of base bead
|
||||||
|
// - attached_molecule stored as self-reference in wisp (meaningless)
|
||||||
|
// - Base bead left orphaned after gt done
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hook the bead using bd update.
|
// Hook the bead using bd update.
|
||||||
@@ -515,17 +483,6 @@ func runSling(cmd *cobra.Command, args []string) error {
|
|||||||
updateAgentHookBead(targetAgent, beadID, hookWorkDir, townBeadsDir)
|
updateAgentHookBead(targetAgent, beadID, hookWorkDir, townBeadsDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-attach mol-polecat-work to polecat agent beads
|
|
||||||
// This ensures polecats have the standard work molecule attached for guidance.
|
|
||||||
// Only do this for bare beads (no --on formula), since formula-on-bead
|
|
||||||
// mode already attaches the formula as a molecule.
|
|
||||||
if formulaName == "" && strings.Contains(targetAgent, "/polecats/") {
|
|
||||||
if err := attachPolecatWorkMolecule(targetAgent, hookWorkDir, townRoot); err != nil {
|
|
||||||
// Warn but don't fail - polecat will still work without molecule
|
|
||||||
fmt.Printf("%s Could not attach work molecule: %v\n", style.Dim.Render("Warning:"), err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store dispatcher in bead description (enables completion notification to dispatcher)
|
// Store dispatcher in bead description (enables completion notification to dispatcher)
|
||||||
if err := storeDispatcherInBead(beadID, actor); err != nil {
|
if err := storeDispatcherInBead(beadID, actor); err != nil {
|
||||||
// Warn but don't fail - polecat will still complete work
|
// Warn but don't fail - polecat will still complete work
|
||||||
@@ -542,8 +499,11 @@ func runSling(cmd *cobra.Command, args []string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Record the attached molecule in the wisp's description.
|
// Record the attached molecule in the BASE bead's description.
|
||||||
// This is required for gt hook to recognize the molecule attachment.
|
// This field points to the wisp (compound root) and enables:
|
||||||
|
// - gt hook/gt prime: follow attached_molecule to show molecule steps
|
||||||
|
// - gt done: close attached_molecule (wisp) before closing hooked bead
|
||||||
|
// - Compound resolution: base bead -> attached_molecule -> wisp
|
||||||
if attachedMoleculeID != "" {
|
if attachedMoleculeID != "" {
|
||||||
if err := storeAttachedMoleculeInBead(beadID, attachedMoleculeID); err != nil {
|
if err := storeAttachedMoleculeInBead(beadID, attachedMoleculeID); err != nil {
|
||||||
// Warn but don't fail - polecat can still work through steps
|
// Warn but don't fail - polecat can still work through steps
|
||||||
|
|||||||
378
internal/cmd/sling_288_test.go
Normal file
378
internal/cmd/sling_288_test.go
Normal file
@@ -0,0 +1,378 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestInstantiateFormulaOnBead verifies the helper function works correctly.
|
||||||
|
// This tests the formula-on-bead pattern used by issue #288.
|
||||||
|
func TestInstantiateFormulaOnBead(t *testing.T) {
|
||||||
|
townRoot := t.TempDir()
|
||||||
|
|
||||||
|
// Minimal workspace marker
|
||||||
|
if err := os.MkdirAll(filepath.Join(townRoot, "mayor", "rig"), 0755); err != nil {
|
||||||
|
t.Fatalf("mkdir mayor/rig: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create routes.jsonl
|
||||||
|
if err := os.MkdirAll(filepath.Join(townRoot, ".beads"), 0755); err != nil {
|
||||||
|
t.Fatalf("mkdir .beads: %v", err)
|
||||||
|
}
|
||||||
|
rigDir := filepath.Join(townRoot, "gastown", "mayor", "rig")
|
||||||
|
if err := os.MkdirAll(rigDir, 0755); err != nil {
|
||||||
|
t.Fatalf("mkdir rigDir: %v", err)
|
||||||
|
}
|
||||||
|
routes := strings.Join([]string{
|
||||||
|
`{"prefix":"gt-","path":"gastown/mayor/rig"}`,
|
||||||
|
`{"prefix":"hq-","path":"."}`,
|
||||||
|
"",
|
||||||
|
}, "\n")
|
||||||
|
if err := os.WriteFile(filepath.Join(townRoot, ".beads", "routes.jsonl"), []byte(routes), 0644); err != nil {
|
||||||
|
t.Fatalf("write routes.jsonl: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create stub bd that logs all commands
|
||||||
|
binDir := filepath.Join(townRoot, "bin")
|
||||||
|
if err := os.MkdirAll(binDir, 0755); err != nil {
|
||||||
|
t.Fatalf("mkdir binDir: %v", err)
|
||||||
|
}
|
||||||
|
logPath := filepath.Join(townRoot, "bd.log")
|
||||||
|
bdScript := `#!/bin/sh
|
||||||
|
set -e
|
||||||
|
echo "CMD:$*" >> "${BD_LOG}"
|
||||||
|
if [ "$1" = "--no-daemon" ]; then
|
||||||
|
shift
|
||||||
|
fi
|
||||||
|
cmd="$1"
|
||||||
|
shift || true
|
||||||
|
case "$cmd" in
|
||||||
|
show)
|
||||||
|
echo '[{"title":"Fix bug ABC","status":"open","assignee":"","description":""}]'
|
||||||
|
;;
|
||||||
|
formula)
|
||||||
|
echo '{"name":"mol-polecat-work"}'
|
||||||
|
;;
|
||||||
|
cook)
|
||||||
|
;;
|
||||||
|
mol)
|
||||||
|
sub="$1"
|
||||||
|
shift || true
|
||||||
|
case "$sub" in
|
||||||
|
wisp)
|
||||||
|
echo '{"new_epic_id":"gt-wisp-288"}'
|
||||||
|
;;
|
||||||
|
bond)
|
||||||
|
echo '{"root_id":"gt-wisp-288"}'
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
;;
|
||||||
|
update)
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
exit 0
|
||||||
|
`
|
||||||
|
bdPath := filepath.Join(binDir, "bd")
|
||||||
|
if err := os.WriteFile(bdPath, []byte(bdScript), 0755); err != nil {
|
||||||
|
t.Fatalf("write bd stub: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Setenv("BD_LOG", logPath)
|
||||||
|
t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||||
|
|
||||||
|
cwd, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("getwd: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = os.Chdir(cwd) })
|
||||||
|
if err := os.Chdir(filepath.Join(townRoot, "mayor", "rig")); err != nil {
|
||||||
|
t.Fatalf("chdir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test the helper function directly
|
||||||
|
result, err := InstantiateFormulaOnBead("mol-polecat-work", "gt-abc123", "Test Bug Fix", "", townRoot, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("InstantiateFormulaOnBead failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.WispRootID == "" {
|
||||||
|
t.Error("WispRootID should not be empty")
|
||||||
|
}
|
||||||
|
if result.BeadToHook == "" {
|
||||||
|
t.Error("BeadToHook should not be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify commands were logged
|
||||||
|
logBytes, err := os.ReadFile(logPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read log: %v", err)
|
||||||
|
}
|
||||||
|
logContent := string(logBytes)
|
||||||
|
|
||||||
|
if !strings.Contains(logContent, "cook mol-polecat-work") {
|
||||||
|
t.Errorf("cook command not found in log:\n%s", logContent)
|
||||||
|
}
|
||||||
|
if !strings.Contains(logContent, "mol wisp mol-polecat-work") {
|
||||||
|
t.Errorf("mol wisp command not found in log:\n%s", logContent)
|
||||||
|
}
|
||||||
|
if !strings.Contains(logContent, "mol bond") {
|
||||||
|
t.Errorf("mol bond command not found in log:\n%s", logContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInstantiateFormulaOnBeadSkipCook verifies the skipCook optimization.
|
||||||
|
func TestInstantiateFormulaOnBeadSkipCook(t *testing.T) {
|
||||||
|
townRoot := t.TempDir()
|
||||||
|
|
||||||
|
// Minimal workspace marker
|
||||||
|
if err := os.MkdirAll(filepath.Join(townRoot, "mayor", "rig"), 0755); err != nil {
|
||||||
|
t.Fatalf("mkdir mayor/rig: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create routes.jsonl
|
||||||
|
if err := os.MkdirAll(filepath.Join(townRoot, ".beads"), 0755); err != nil {
|
||||||
|
t.Fatalf("mkdir .beads: %v", err)
|
||||||
|
}
|
||||||
|
routes := `{"prefix":"gt-","path":"."}`
|
||||||
|
if err := os.WriteFile(filepath.Join(townRoot, ".beads", "routes.jsonl"), []byte(routes), 0644); err != nil {
|
||||||
|
t.Fatalf("write routes.jsonl: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create stub bd
|
||||||
|
binDir := filepath.Join(townRoot, "bin")
|
||||||
|
if err := os.MkdirAll(binDir, 0755); err != nil {
|
||||||
|
t.Fatalf("mkdir binDir: %v", err)
|
||||||
|
}
|
||||||
|
logPath := filepath.Join(townRoot, "bd.log")
|
||||||
|
bdScript := `#!/bin/sh
|
||||||
|
echo "CMD:$*" >> "${BD_LOG}"
|
||||||
|
if [ "$1" = "--no-daemon" ]; then shift; fi
|
||||||
|
cmd="$1"; shift || true
|
||||||
|
case "$cmd" in
|
||||||
|
mol)
|
||||||
|
sub="$1"; shift || true
|
||||||
|
case "$sub" in
|
||||||
|
wisp) echo '{"new_epic_id":"gt-wisp-skip"}';;
|
||||||
|
bond) echo '{"root_id":"gt-wisp-skip"}';;
|
||||||
|
esac;;
|
||||||
|
esac
|
||||||
|
exit 0
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(filepath.Join(binDir, "bd"), []byte(bdScript), 0755); err != nil {
|
||||||
|
t.Fatalf("write bd stub: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Setenv("BD_LOG", logPath)
|
||||||
|
t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||||
|
|
||||||
|
cwd, _ := os.Getwd()
|
||||||
|
t.Cleanup(func() { _ = os.Chdir(cwd) })
|
||||||
|
_ = os.Chdir(townRoot)
|
||||||
|
|
||||||
|
// Test with skipCook=true
|
||||||
|
_, err := InstantiateFormulaOnBead("mol-polecat-work", "gt-test", "Test", "", townRoot, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("InstantiateFormulaOnBead failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logBytes, _ := os.ReadFile(logPath)
|
||||||
|
logContent := string(logBytes)
|
||||||
|
|
||||||
|
// Verify cook was NOT called when skipCook=true
|
||||||
|
if strings.Contains(logContent, "cook") {
|
||||||
|
t.Errorf("cook should be skipped when skipCook=true, but was called:\n%s", logContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify wisp and bond were still called
|
||||||
|
if !strings.Contains(logContent, "mol wisp") {
|
||||||
|
t.Errorf("mol wisp should still be called")
|
||||||
|
}
|
||||||
|
if !strings.Contains(logContent, "mol bond") {
|
||||||
|
t.Errorf("mol bond should still be called")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCookFormula verifies the CookFormula helper.
|
||||||
|
func TestCookFormula(t *testing.T) {
|
||||||
|
townRoot := t.TempDir()
|
||||||
|
|
||||||
|
binDir := filepath.Join(townRoot, "bin")
|
||||||
|
if err := os.MkdirAll(binDir, 0755); err != nil {
|
||||||
|
t.Fatalf("mkdir binDir: %v", err)
|
||||||
|
}
|
||||||
|
logPath := filepath.Join(townRoot, "bd.log")
|
||||||
|
bdScript := `#!/bin/sh
|
||||||
|
echo "CMD:$*" >> "${BD_LOG}"
|
||||||
|
exit 0
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(filepath.Join(binDir, "bd"), []byte(bdScript), 0755); err != nil {
|
||||||
|
t.Fatalf("write bd stub: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Setenv("BD_LOG", logPath)
|
||||||
|
t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||||
|
|
||||||
|
err := CookFormula("mol-polecat-work", townRoot)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CookFormula failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logBytes, _ := os.ReadFile(logPath)
|
||||||
|
if !strings.Contains(string(logBytes), "cook mol-polecat-work") {
|
||||||
|
t.Errorf("cook command not found in log")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSlingHookRawBeadFlag verifies --hook-raw-bead flag exists.
|
||||||
|
func TestSlingHookRawBeadFlag(t *testing.T) {
|
||||||
|
// Verify the flag variable exists and works
|
||||||
|
prevValue := slingHookRawBead
|
||||||
|
t.Cleanup(func() { slingHookRawBead = prevValue })
|
||||||
|
|
||||||
|
slingHookRawBead = true
|
||||||
|
if !slingHookRawBead {
|
||||||
|
t.Error("slingHookRawBead flag should be true")
|
||||||
|
}
|
||||||
|
|
||||||
|
slingHookRawBead = false
|
||||||
|
if slingHookRawBead {
|
||||||
|
t.Error("slingHookRawBead flag should be false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAutoApplyLogic verifies the auto-apply detection logic.
|
||||||
|
// When formulaName is empty and target contains "/polecats/", mol-polecat-work should be applied.
|
||||||
|
func TestAutoApplyLogic(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
formulaName string
|
||||||
|
hookRawBead bool
|
||||||
|
targetAgent string
|
||||||
|
wantAutoApply bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "bare bead to polecat - should auto-apply",
|
||||||
|
formulaName: "",
|
||||||
|
hookRawBead: false,
|
||||||
|
targetAgent: "gastown/polecats/Toast",
|
||||||
|
wantAutoApply: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bare bead with --hook-raw-bead - should not auto-apply",
|
||||||
|
formulaName: "",
|
||||||
|
hookRawBead: true,
|
||||||
|
targetAgent: "gastown/polecats/Toast",
|
||||||
|
wantAutoApply: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "formula already specified - should not auto-apply",
|
||||||
|
formulaName: "mol-review",
|
||||||
|
hookRawBead: false,
|
||||||
|
targetAgent: "gastown/polecats/Toast",
|
||||||
|
wantAutoApply: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non-polecat target - should not auto-apply",
|
||||||
|
formulaName: "",
|
||||||
|
hookRawBead: false,
|
||||||
|
targetAgent: "gastown/witness",
|
||||||
|
wantAutoApply: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mayor target - should not auto-apply",
|
||||||
|
formulaName: "",
|
||||||
|
hookRawBead: false,
|
||||||
|
targetAgent: "mayor",
|
||||||
|
wantAutoApply: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
// This mirrors the logic in sling.go
|
||||||
|
shouldAutoApply := tt.formulaName == "" && !tt.hookRawBead && strings.Contains(tt.targetAgent, "/polecats/")
|
||||||
|
|
||||||
|
if shouldAutoApply != tt.wantAutoApply {
|
||||||
|
t.Errorf("auto-apply logic: got %v, want %v", shouldAutoApply, tt.wantAutoApply)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFormulaOnBeadPassesVariables verifies that feature and issue variables are passed.
|
||||||
|
func TestFormulaOnBeadPassesVariables(t *testing.T) {
|
||||||
|
townRoot := t.TempDir()
|
||||||
|
|
||||||
|
// Minimal workspace
|
||||||
|
if err := os.MkdirAll(filepath.Join(townRoot, "mayor", "rig"), 0755); err != nil {
|
||||||
|
t.Fatalf("mkdir: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Join(townRoot, ".beads"), 0755); err != nil {
|
||||||
|
t.Fatalf("mkdir .beads: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(townRoot, ".beads", "routes.jsonl"), []byte(`{"prefix":"gt-","path":"."}`), 0644); err != nil {
|
||||||
|
t.Fatalf("write routes: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
binDir := filepath.Join(townRoot, "bin")
|
||||||
|
if err := os.MkdirAll(binDir, 0755); err != nil {
|
||||||
|
t.Fatalf("mkdir binDir: %v", err)
|
||||||
|
}
|
||||||
|
logPath := filepath.Join(townRoot, "bd.log")
|
||||||
|
bdScript := `#!/bin/sh
|
||||||
|
echo "CMD:$*" >> "${BD_LOG}"
|
||||||
|
if [ "$1" = "--no-daemon" ]; then shift; fi
|
||||||
|
cmd="$1"; shift || true
|
||||||
|
case "$cmd" in
|
||||||
|
cook) exit 0;;
|
||||||
|
mol)
|
||||||
|
sub="$1"; shift || true
|
||||||
|
case "$sub" in
|
||||||
|
wisp) echo '{"new_epic_id":"gt-wisp-var"}';;
|
||||||
|
bond) echo '{"root_id":"gt-wisp-var"}';;
|
||||||
|
esac;;
|
||||||
|
esac
|
||||||
|
exit 0
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(filepath.Join(binDir, "bd"), []byte(bdScript), 0755); err != nil {
|
||||||
|
t.Fatalf("write bd: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Setenv("BD_LOG", logPath)
|
||||||
|
t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||||
|
|
||||||
|
cwd, _ := os.Getwd()
|
||||||
|
t.Cleanup(func() { _ = os.Chdir(cwd) })
|
||||||
|
_ = os.Chdir(townRoot)
|
||||||
|
|
||||||
|
_, err := InstantiateFormulaOnBead("mol-polecat-work", "gt-abc123", "My Cool Feature", "", townRoot, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("InstantiateFormulaOnBead: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logBytes, _ := os.ReadFile(logPath)
|
||||||
|
logContent := string(logBytes)
|
||||||
|
|
||||||
|
// Find mol wisp line
|
||||||
|
var wispLine string
|
||||||
|
for _, line := range strings.Split(logContent, "\n") {
|
||||||
|
if strings.Contains(line, "mol wisp") {
|
||||||
|
wispLine = line
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if wispLine == "" {
|
||||||
|
t.Fatalf("mol wisp command not found:\n%s", logContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(wispLine, "feature=My Cool Feature") {
|
||||||
|
t.Errorf("mol wisp missing feature variable:\n%s", wispLine)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(wispLine, "issue=gt-abc123") {
|
||||||
|
t.Errorf("mol wisp missing issue variable:\n%s", wispLine)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,14 +23,21 @@ func runBatchSling(beadIDs []string, rigName string, townBeadsDir string) error
|
|||||||
|
|
||||||
if slingDryRun {
|
if slingDryRun {
|
||||||
fmt.Printf("%s Batch slinging %d beads to rig '%s':\n", style.Bold.Render("🎯"), len(beadIDs), rigName)
|
fmt.Printf("%s Batch slinging %d beads to rig '%s':\n", style.Bold.Render("🎯"), len(beadIDs), rigName)
|
||||||
|
fmt.Printf(" Would cook mol-polecat-work formula once\n")
|
||||||
for _, beadID := range beadIDs {
|
for _, beadID := range beadIDs {
|
||||||
fmt.Printf(" Would spawn polecat for: %s\n", beadID)
|
fmt.Printf(" Would spawn polecat and apply mol-polecat-work to: %s\n", beadID)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("%s Batch slinging %d beads to rig '%s'...\n", style.Bold.Render("🎯"), len(beadIDs), rigName)
|
fmt.Printf("%s Batch slinging %d beads to rig '%s'...\n", style.Bold.Render("🎯"), len(beadIDs), rigName)
|
||||||
|
|
||||||
|
// Issue #288: Auto-apply mol-polecat-work for batch sling
|
||||||
|
// Cook once before the loop for efficiency
|
||||||
|
townRoot := filepath.Dir(townBeadsDir)
|
||||||
|
formulaName := "mol-polecat-work"
|
||||||
|
formulaCooked := false
|
||||||
|
|
||||||
// Track results for summary
|
// Track results for summary
|
||||||
type slingResult struct {
|
type slingResult struct {
|
||||||
beadID string
|
beadID string
|
||||||
@@ -91,10 +98,34 @@ func runBatchSling(beadIDs []string, rigName string, townBeadsDir string) error
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hook the bead. See: https://github.com/steveyegge/gastown/issues/148
|
// Issue #288: Apply mol-polecat-work via formula-on-bead pattern
|
||||||
townRoot := filepath.Dir(townBeadsDir)
|
// Cook once (lazy), then instantiate for each bead
|
||||||
hookCmd := exec.Command("bd", "--no-daemon", "update", beadID, "--status=hooked", "--assignee="+targetAgent)
|
if !formulaCooked {
|
||||||
hookCmd.Dir = beads.ResolveHookDir(townRoot, beadID, hookWorkDir)
|
workDir := beads.ResolveHookDir(townRoot, beadID, hookWorkDir)
|
||||||
|
if err := CookFormula(formulaName, workDir); err != nil {
|
||||||
|
fmt.Printf(" %s Could not cook formula %s: %v\n", style.Dim.Render("Warning:"), formulaName, err)
|
||||||
|
// Fall back to raw hook if formula cook fails
|
||||||
|
} else {
|
||||||
|
formulaCooked = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
beadToHook := beadID
|
||||||
|
attachedMoleculeID := ""
|
||||||
|
if formulaCooked {
|
||||||
|
result, err := InstantiateFormulaOnBead(formulaName, beadID, info.Title, hookWorkDir, townRoot, true)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf(" %s Could not apply formula: %v (hooking raw bead)\n", style.Dim.Render("Warning:"), err)
|
||||||
|
} else {
|
||||||
|
fmt.Printf(" %s Formula %s applied\n", style.Bold.Render("✓"), formulaName)
|
||||||
|
beadToHook = result.BeadToHook
|
||||||
|
attachedMoleculeID = result.WispRootID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hook the bead (or wisp compound if formula was applied)
|
||||||
|
hookCmd := exec.Command("bd", "--no-daemon", "update", beadToHook, "--status=hooked", "--assignee="+targetAgent)
|
||||||
|
hookCmd.Dir = beads.ResolveHookDir(townRoot, beadToHook, hookWorkDir)
|
||||||
hookCmd.Stderr = os.Stderr
|
hookCmd.Stderr = os.Stderr
|
||||||
if err := hookCmd.Run(); err != nil {
|
if err := hookCmd.Run(); err != nil {
|
||||||
results = append(results, slingResult{beadID: beadID, polecat: spawnInfo.PolecatName, success: false, errMsg: "hook failed"})
|
results = append(results, slingResult{beadID: beadID, polecat: spawnInfo.PolecatName, success: false, errMsg: "hook failed"})
|
||||||
@@ -106,14 +137,16 @@ func runBatchSling(beadIDs []string, rigName string, townBeadsDir string) error
|
|||||||
|
|
||||||
// Log sling event
|
// Log sling event
|
||||||
actor := detectActor()
|
actor := detectActor()
|
||||||
_ = events.LogFeed(events.TypeSling, actor, events.SlingPayload(beadID, targetAgent))
|
_ = events.LogFeed(events.TypeSling, actor, events.SlingPayload(beadToHook, targetAgent))
|
||||||
|
|
||||||
// Update agent bead state
|
// Update agent bead state
|
||||||
updateAgentHookBead(targetAgent, beadID, hookWorkDir, townBeadsDir)
|
updateAgentHookBead(targetAgent, beadToHook, hookWorkDir, townBeadsDir)
|
||||||
|
|
||||||
// Auto-attach mol-polecat-work molecule to polecat agent bead
|
// Store attached molecule in the hooked bead
|
||||||
if err := attachPolecatWorkMolecule(targetAgent, hookWorkDir, townRoot); err != nil {
|
if attachedMoleculeID != "" {
|
||||||
fmt.Printf(" %s Could not attach work molecule: %v\n", style.Dim.Render("Warning:"), err)
|
if err := storeAttachedMoleculeInBead(beadToHook, attachedMoleculeID); err != nil {
|
||||||
|
fmt.Printf(" %s Could not store attached_molecule: %v\n", style.Dim.Render("Warning:"), err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store args if provided
|
// Store args if provided
|
||||||
|
|||||||
@@ -9,9 +9,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/steveyegge/gastown/internal/beads"
|
"github.com/steveyegge/gastown/internal/beads"
|
||||||
"github.com/steveyegge/gastown/internal/config"
|
|
||||||
"github.com/steveyegge/gastown/internal/constants"
|
"github.com/steveyegge/gastown/internal/constants"
|
||||||
"github.com/steveyegge/gastown/internal/style"
|
|
||||||
"github.com/steveyegge/gastown/internal/tmux"
|
"github.com/steveyegge/gastown/internal/tmux"
|
||||||
"github.com/steveyegge/gastown/internal/workspace"
|
"github.com/steveyegge/gastown/internal/workspace"
|
||||||
)
|
)
|
||||||
@@ -461,57 +459,86 @@ func isPolecatTarget(target string) bool {
|
|||||||
return len(parts) >= 3 && parts[1] == "polecats"
|
return len(parts) >= 3 && parts[1] == "polecats"
|
||||||
}
|
}
|
||||||
|
|
||||||
// attachPolecatWorkMolecule attaches the mol-polecat-work molecule to a polecat's agent bead.
|
// FormulaOnBeadResult contains the result of instantiating a formula on a bead.
|
||||||
// This ensures all polecats have the standard work molecule attached for guidance.
|
type FormulaOnBeadResult struct {
|
||||||
// The molecule is attached by storing it in the agent bead's description using attachment fields.
|
WispRootID string // The wisp root ID (compound root after bonding)
|
||||||
//
|
BeadToHook string // The bead ID to hook (BASE bead, not wisp - lifecycle fix)
|
||||||
// Per issue #288: gt sling should auto-attach mol-polecat-work when slinging to polecats.
|
}
|
||||||
func attachPolecatWorkMolecule(targetAgent, hookWorkDir, townRoot string) error {
|
|
||||||
// Parse the polecat name from targetAgent (format: "rig/polecats/name")
|
// InstantiateFormulaOnBead creates a wisp from a formula, bonds it to a bead.
|
||||||
parts := strings.Split(targetAgent, "/")
|
// This is the formula-on-bead pattern used by issue #288 for auto-applying mol-polecat-work.
|
||||||
if len(parts) != 3 || parts[1] != "polecats" {
|
//
|
||||||
return fmt.Errorf("invalid polecat agent format: %s", targetAgent)
|
// Parameters:
|
||||||
}
|
// - formulaName: the formula to instantiate (e.g., "mol-polecat-work")
|
||||||
rigName := parts[0]
|
// - beadID: the base bead to bond the wisp to
|
||||||
polecatName := parts[2]
|
// - title: the bead title (used for --var feature=<title>)
|
||||||
|
// - hookWorkDir: working directory for bd commands (polecat's worktree)
|
||||||
// Get the polecat's agent bead ID
|
// - townRoot: the town root directory
|
||||||
// Format: "<prefix>-<rig>-polecat-<name>" (e.g., "gt-gastown-polecat-Toast")
|
// - skipCook: if true, skip cooking (for batch mode optimization where cook happens once)
|
||||||
prefix := config.GetRigPrefix(townRoot, rigName)
|
//
|
||||||
agentBeadID := beads.PolecatBeadIDWithPrefix(prefix, rigName, polecatName)
|
// Returns the wisp root ID which should be hooked.
|
||||||
|
func InstantiateFormulaOnBead(formulaName, beadID, title, hookWorkDir, townRoot string, skipCook bool) (*FormulaOnBeadResult, error) {
|
||||||
// Resolve the rig directory for running bd commands.
|
// Route bd mutations (wisp/bond) to the correct beads context for the target bead.
|
||||||
// Use ResolveHookDir to ensure we run bd from the correct rig directory
|
formulaWorkDir := beads.ResolveHookDir(townRoot, beadID, hookWorkDir)
|
||||||
// (not from the polecat's worktree, which doesn't have a .beads directory).
|
|
||||||
// This fixes issue #197: polecat fails to hook when slinging with molecule.
|
// Step 1: Cook the formula (ensures proto exists)
|
||||||
rigDir := beads.ResolveHookDir(townRoot, prefix+"-"+polecatName, hookWorkDir)
|
if !skipCook {
|
||||||
|
cookCmd := exec.Command("bd", "--no-daemon", "cook", formulaName)
|
||||||
b := beads.New(rigDir)
|
cookCmd.Dir = formulaWorkDir
|
||||||
|
cookCmd.Stderr = os.Stderr
|
||||||
// Check if molecule is already attached (avoid duplicate attach)
|
if err := cookCmd.Run(); err != nil {
|
||||||
attachment, err := b.GetAttachment(agentBeadID)
|
return nil, fmt.Errorf("cooking formula %s: %w", formulaName, err)
|
||||||
if err == nil && attachment != nil && attachment.AttachedMolecule != "" {
|
}
|
||||||
// Already has a molecule attached - skip
|
}
|
||||||
return nil
|
|
||||||
}
|
// Step 2: Create wisp with feature and issue variables from bead
|
||||||
|
featureVar := fmt.Sprintf("feature=%s", title)
|
||||||
// Cook the mol-polecat-work formula to ensure the proto exists
|
issueVar := fmt.Sprintf("issue=%s", beadID)
|
||||||
// This is safe to run multiple times - cooking is idempotent
|
wispArgs := []string{"--no-daemon", "mol", "wisp", formulaName, "--var", featureVar, "--var", issueVar, "--json"}
|
||||||
cookCmd := exec.Command("bd", "--no-daemon", "cook", "mol-polecat-work")
|
wispCmd := exec.Command("bd", wispArgs...)
|
||||||
cookCmd.Dir = rigDir
|
wispCmd.Dir = formulaWorkDir
|
||||||
cookCmd.Stderr = os.Stderr
|
wispCmd.Env = append(os.Environ(), "GT_ROOT="+townRoot)
|
||||||
if err := cookCmd.Run(); err != nil {
|
wispCmd.Stderr = os.Stderr
|
||||||
return fmt.Errorf("cooking mol-polecat-work formula: %w", err)
|
wispOut, err := wispCmd.Output()
|
||||||
}
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("creating wisp for formula %s: %w", formulaName, err)
|
||||||
// Attach the molecule to the polecat's agent bead
|
}
|
||||||
// The molecule ID is the formula name "mol-polecat-work"
|
|
||||||
moleculeID := "mol-polecat-work"
|
// Parse wisp output to get the root ID
|
||||||
_, err = b.AttachMolecule(agentBeadID, moleculeID)
|
wispRootID, err := parseWispIDFromJSON(wispOut)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("attaching molecule %s to %s: %w", moleculeID, agentBeadID, err)
|
return nil, fmt.Errorf("parsing wisp output: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("%s Attached %s to %s\n", style.Bold.Render("✓"), moleculeID, agentBeadID)
|
// Step 3: Bond wisp to original bead (creates compound)
|
||||||
return nil
|
bondArgs := []string{"--no-daemon", "mol", "bond", wispRootID, beadID, "--json"}
|
||||||
|
bondCmd := exec.Command("bd", bondArgs...)
|
||||||
|
bondCmd.Dir = formulaWorkDir
|
||||||
|
bondCmd.Stderr = os.Stderr
|
||||||
|
bondOut, err := bondCmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("bonding formula to bead: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse bond output - the wisp root becomes the compound root
|
||||||
|
var bondResult struct {
|
||||||
|
RootID string `json:"root_id"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(bondOut, &bondResult); err == nil && bondResult.RootID != "" {
|
||||||
|
wispRootID = bondResult.RootID
|
||||||
|
}
|
||||||
|
|
||||||
|
return &FormulaOnBeadResult{
|
||||||
|
WispRootID: wispRootID,
|
||||||
|
BeadToHook: beadID, // Hook the BASE bead (lifecycle fix: wisp is attached_molecule)
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CookFormula cooks a formula to ensure its proto exists.
|
||||||
|
// This is useful for batch mode where we cook once before processing multiple beads.
|
||||||
|
func CookFormula(formulaName, workDir string) error {
|
||||||
|
cookCmd := exec.Command("bd", "--no-daemon", "cook", formulaName)
|
||||||
|
cookCmd.Dir = workDir
|
||||||
|
cookCmd.Stderr = os.Stderr
|
||||||
|
return cookCmd.Run()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user