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)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
_, role, _, ok := ParseAgentBeadID(pinnedBeadID)
|
||||
if !(issue.Status == "open" && ok && role == "polecat") {
|
||||
return nil, fmt.Errorf("issue %s is not pinned or open polecat (status: %s)", pinnedBeadID, issue.Status)
|
||||
}
|
||||
return nil, fmt.Errorf("issue %s is not pinned (status: %s)", pinnedBeadID, issue.Status)
|
||||
}
|
||||
|
||||
// Build attachment fields with current timestamp
|
||||
|
||||
@@ -603,6 +603,19 @@ func updateAgentStateOnDone(cwd, townRoot, exitType, _ string) { // issueID unus
|
||||
hookedBeadID := agentBead.HookBead
|
||||
// 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 {
|
||||
// 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 {
|
||||
// Non-fatal: warn but continue
|
||||
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()
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -83,12 +82,13 @@ Batch Slinging:
|
||||
}
|
||||
|
||||
var (
|
||||
slingSubject string
|
||||
slingMessage string
|
||||
slingDryRun bool
|
||||
slingOnTarget string // --on flag: target bead when slinging a formula
|
||||
slingVars []string // --var flag: formula variables (key=value)
|
||||
slingArgs string // --args flag: natural language instructions for executor
|
||||
slingSubject string
|
||||
slingMessage string
|
||||
slingDryRun bool
|
||||
slingOnTarget string // --on flag: target bead when slinging a formula
|
||||
slingVars []string // --var flag: formula variables (key=value)
|
||||
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)
|
||||
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(&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(&slingHookRawBead, "hook-raw-bead", false, "Hook raw bead without default formula (expert mode)")
|
||||
|
||||
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 formulaName != "" {
|
||||
fmt.Printf("Would instantiate formula %s:\n", formulaName)
|
||||
@@ -425,71 +434,30 @@ func runSling(cmd *cobra.Command, args []string) error {
|
||||
if formulaName != "" {
|
||||
fmt.Printf(" Instantiating formula %s...\n", formulaName)
|
||||
|
||||
// Route bd mutations (wisp/bond) to the correct beads context for the target bead.
|
||||
// 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()
|
||||
result, err := InstantiateFormulaOnBead(formulaName, beadID, info.Title, hookWorkDir, townRoot, false)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating wisp for 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
|
||||
return fmt.Errorf("instantiating formula %s: %w", formulaName, err)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
// Record attached molecule after other description updates to avoid overwrite.
|
||||
attachedMoleculeID = wispRootID
|
||||
// Record attached molecule - will be stored in BASE bead (not wisp).
|
||||
// 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
|
||||
beadID = wispRootID
|
||||
// NOTE: We intentionally keep beadID as the ORIGINAL base bead, not the wisp.
|
||||
// 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.
|
||||
@@ -515,17 +483,6 @@ func runSling(cmd *cobra.Command, args []string) error {
|
||||
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)
|
||||
if err := storeDispatcherInBead(beadID, actor); err != nil {
|
||||
// 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.
|
||||
// This is required for gt hook to recognize the molecule attachment.
|
||||
// Record the attached molecule in the BASE bead's description.
|
||||
// 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 err := storeAttachedMoleculeInBead(beadID, attachedMoleculeID); err != nil {
|
||||
// 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 {
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
type slingResult struct {
|
||||
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
|
||||
townRoot := filepath.Dir(townBeadsDir)
|
||||
hookCmd := exec.Command("bd", "--no-daemon", "update", beadID, "--status=hooked", "--assignee="+targetAgent)
|
||||
hookCmd.Dir = beads.ResolveHookDir(townRoot, beadID, hookWorkDir)
|
||||
// Issue #288: Apply mol-polecat-work via formula-on-bead pattern
|
||||
// Cook once (lazy), then instantiate for each bead
|
||||
if !formulaCooked {
|
||||
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
|
||||
if err := hookCmd.Run(); err != nil {
|
||||
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
|
||||
actor := detectActor()
|
||||
_ = events.LogFeed(events.TypeSling, actor, events.SlingPayload(beadID, targetAgent))
|
||||
_ = events.LogFeed(events.TypeSling, actor, events.SlingPayload(beadToHook, targetAgent))
|
||||
|
||||
// Update agent bead state
|
||||
updateAgentHookBead(targetAgent, beadID, hookWorkDir, townBeadsDir)
|
||||
updateAgentHookBead(targetAgent, beadToHook, hookWorkDir, townBeadsDir)
|
||||
|
||||
// Auto-attach mol-polecat-work molecule to polecat agent bead
|
||||
if err := attachPolecatWorkMolecule(targetAgent, hookWorkDir, townRoot); err != nil {
|
||||
fmt.Printf(" %s Could not attach work molecule: %v\n", style.Dim.Render("Warning:"), err)
|
||||
// Store attached molecule in the hooked bead
|
||||
if attachedMoleculeID != "" {
|
||||
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
|
||||
|
||||
@@ -9,9 +9,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/steveyegge/gastown/internal/beads"
|
||||
"github.com/steveyegge/gastown/internal/config"
|
||||
"github.com/steveyegge/gastown/internal/constants"
|
||||
"github.com/steveyegge/gastown/internal/style"
|
||||
"github.com/steveyegge/gastown/internal/tmux"
|
||||
"github.com/steveyegge/gastown/internal/workspace"
|
||||
)
|
||||
@@ -461,57 +459,86 @@ func isPolecatTarget(target string) bool {
|
||||
return len(parts) >= 3 && parts[1] == "polecats"
|
||||
}
|
||||
|
||||
// attachPolecatWorkMolecule attaches the mol-polecat-work molecule to a polecat's agent bead.
|
||||
// This ensures all polecats have the standard work molecule attached for guidance.
|
||||
// The molecule is attached by storing it in the agent bead's description using attachment fields.
|
||||
//
|
||||
// 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")
|
||||
parts := strings.Split(targetAgent, "/")
|
||||
if len(parts) != 3 || parts[1] != "polecats" {
|
||||
return fmt.Errorf("invalid polecat agent format: %s", targetAgent)
|
||||
}
|
||||
rigName := parts[0]
|
||||
polecatName := parts[2]
|
||||
|
||||
// Get the polecat's agent bead ID
|
||||
// Format: "<prefix>-<rig>-polecat-<name>" (e.g., "gt-gastown-polecat-Toast")
|
||||
prefix := config.GetRigPrefix(townRoot, rigName)
|
||||
agentBeadID := beads.PolecatBeadIDWithPrefix(prefix, rigName, polecatName)
|
||||
|
||||
// Resolve the rig directory for running bd commands.
|
||||
// Use ResolveHookDir to ensure we run bd from the correct rig directory
|
||||
// (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.
|
||||
rigDir := beads.ResolveHookDir(townRoot, prefix+"-"+polecatName, hookWorkDir)
|
||||
|
||||
b := beads.New(rigDir)
|
||||
|
||||
// Check if molecule is already attached (avoid duplicate attach)
|
||||
attachment, err := b.GetAttachment(agentBeadID)
|
||||
if err == nil && attachment != nil && attachment.AttachedMolecule != "" {
|
||||
// Already has a molecule attached - skip
|
||||
return nil
|
||||
}
|
||||
|
||||
// Cook the mol-polecat-work formula to ensure the proto exists
|
||||
// This is safe to run multiple times - cooking is idempotent
|
||||
cookCmd := exec.Command("bd", "--no-daemon", "cook", "mol-polecat-work")
|
||||
cookCmd.Dir = rigDir
|
||||
cookCmd.Stderr = os.Stderr
|
||||
if err := cookCmd.Run(); err != nil {
|
||||
return fmt.Errorf("cooking mol-polecat-work formula: %w", err)
|
||||
}
|
||||
|
||||
// Attach the molecule to the polecat's agent bead
|
||||
// The molecule ID is the formula name "mol-polecat-work"
|
||||
moleculeID := "mol-polecat-work"
|
||||
_, err = b.AttachMolecule(agentBeadID, moleculeID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("attaching molecule %s to %s: %w", moleculeID, agentBeadID, err)
|
||||
}
|
||||
|
||||
fmt.Printf("%s Attached %s to %s\n", style.Bold.Render("✓"), moleculeID, agentBeadID)
|
||||
return nil
|
||||
// FormulaOnBeadResult contains the result of instantiating a formula on a bead.
|
||||
type FormulaOnBeadResult struct {
|
||||
WispRootID string // The wisp root ID (compound root after bonding)
|
||||
BeadToHook string // The bead ID to hook (BASE bead, not wisp - lifecycle fix)
|
||||
}
|
||||
|
||||
// InstantiateFormulaOnBead creates a wisp from a formula, bonds it to a bead.
|
||||
// This is the formula-on-bead pattern used by issue #288 for auto-applying mol-polecat-work.
|
||||
//
|
||||
// Parameters:
|
||||
// - formulaName: the formula to instantiate (e.g., "mol-polecat-work")
|
||||
// - beadID: the base bead to bond the wisp to
|
||||
// - title: the bead title (used for --var feature=<title>)
|
||||
// - hookWorkDir: working directory for bd commands (polecat's worktree)
|
||||
// - townRoot: the town root directory
|
||||
// - skipCook: if true, skip cooking (for batch mode optimization where cook happens once)
|
||||
//
|
||||
// Returns the wisp root ID which should be hooked.
|
||||
func InstantiateFormulaOnBead(formulaName, beadID, title, hookWorkDir, townRoot string, skipCook bool) (*FormulaOnBeadResult, error) {
|
||||
// Route bd mutations (wisp/bond) to the correct beads context for the target bead.
|
||||
formulaWorkDir := beads.ResolveHookDir(townRoot, beadID, hookWorkDir)
|
||||
|
||||
// Step 1: Cook the formula (ensures proto exists)
|
||||
if !skipCook {
|
||||
cookCmd := exec.Command("bd", "--no-daemon", "cook", formulaName)
|
||||
cookCmd.Dir = formulaWorkDir
|
||||
cookCmd.Stderr = os.Stderr
|
||||
if err := cookCmd.Run(); err != nil {
|
||||
return nil, fmt.Errorf("cooking formula %s: %w", formulaName, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Create wisp with feature and issue variables from bead
|
||||
featureVar := fmt.Sprintf("feature=%s", 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 {
|
||||
return nil, fmt.Errorf("creating wisp for formula %s: %w", formulaName, err)
|
||||
}
|
||||
|
||||
// Parse wisp output to get the root ID
|
||||
wispRootID, err := parseWispIDFromJSON(wispOut)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing wisp output: %w", err)
|
||||
}
|
||||
|
||||
// Step 3: Bond wisp to original bead (creates compound)
|
||||
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