fix(sling): set attached_molecule field when bonding formula to bead (#451)
When using `gt sling <formula> --on <bead>`, the wisp was bonded to the target bead but the attached_molecule field wasn't being set in the bead's description. This caused `gt hook` to report "No molecule attached" even though the formula was correctly bonded. Now both sling.go (--on mode) and sling_formula.go (standalone formula) call storeAttachedMoleculeInBead() to record the molecule attachment after wisp creation. This ensures gt hook can properly display molecule progress. Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -434,6 +434,13 @@ func runSling(cmd *cobra.Command, args []string) error {
|
|||||||
|
|
||||||
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 the attached molecule in the wisp's description.
|
||||||
|
// This is required for gt hook to recognize the molecule attachment.
|
||||||
|
if err := storeAttachedMoleculeInBead(wispRootID, wispRootID); err != nil {
|
||||||
|
// Warn but don't fail - polecat can still work through steps
|
||||||
|
fmt.Printf("%s Could not store attached_molecule: %v\n", style.Dim.Render("Warning:"), err)
|
||||||
|
}
|
||||||
|
|
||||||
// Update beadID to hook the compound root instead of bare bead
|
// Update beadID to hook the compound root instead of bare bead
|
||||||
beadID = wispRootID
|
beadID = wispRootID
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -210,6 +210,13 @@ func runSlingFormula(args []string) error {
|
|||||||
|
|
||||||
fmt.Printf("%s Wisp created: %s\n", style.Bold.Render("✓"), wispRootID)
|
fmt.Printf("%s Wisp created: %s\n", style.Bold.Render("✓"), wispRootID)
|
||||||
|
|
||||||
|
// Record the attached molecule in the wisp's description.
|
||||||
|
// This is required for gt hook to recognize the molecule attachment.
|
||||||
|
if err := storeAttachedMoleculeInBead(wispRootID, wispRootID); err != nil {
|
||||||
|
// Warn but don't fail - polecat can still work through steps
|
||||||
|
fmt.Printf("%s Could not store attached_molecule: %v\n", style.Dim.Render("Warning:"), err)
|
||||||
|
}
|
||||||
|
|
||||||
// Step 3: Hook the wisp bead using bd update.
|
// Step 3: Hook the wisp bead using bd update.
|
||||||
// See: https://github.com/steveyegge/gastown/issues/148
|
// See: https://github.com/steveyegge/gastown/issues/148
|
||||||
hookCmd := exec.Command("bd", "--no-daemon", "update", wispRootID, "--status=hooked", "--assignee="+targetAgent)
|
hookCmd := exec.Command("bd", "--no-daemon", "update", wispRootID, "--status=hooked", "--assignee="+targetAgent)
|
||||||
|
|||||||
@@ -170,6 +170,56 @@ func storeDispatcherInBead(beadID, dispatcher string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// storeAttachedMoleculeInBead sets the attached_molecule field in a bead's description.
|
||||||
|
// This is required for gt hook to recognize that a molecule is attached to the bead.
|
||||||
|
// Called after bonding a formula wisp to a bead via "gt sling <formula> --on <bead>".
|
||||||
|
func storeAttachedMoleculeInBead(beadID, moleculeID string) error {
|
||||||
|
if moleculeID == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the bead to preserve existing description content
|
||||||
|
showCmd := exec.Command("bd", "show", beadID, "--json")
|
||||||
|
out, err := showCmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("fetching bead: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the bead
|
||||||
|
var issues []beads.Issue
|
||||||
|
if err := json.Unmarshal(out, &issues); err != nil {
|
||||||
|
return fmt.Errorf("parsing bead: %w", err)
|
||||||
|
}
|
||||||
|
if len(issues) == 0 {
|
||||||
|
return fmt.Errorf("bead not found")
|
||||||
|
}
|
||||||
|
issue := &issues[0]
|
||||||
|
|
||||||
|
// Get or create attachment fields
|
||||||
|
fields := beads.ParseAttachmentFields(issue)
|
||||||
|
if fields == nil {
|
||||||
|
fields = &beads.AttachmentFields{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the attached molecule
|
||||||
|
fields.AttachedMolecule = moleculeID
|
||||||
|
if fields.AttachedAt == "" {
|
||||||
|
fields.AttachedAt = time.Now().UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the description
|
||||||
|
newDesc := beads.SetAttachmentFields(issue, fields)
|
||||||
|
|
||||||
|
// Update the bead
|
||||||
|
updateCmd := exec.Command("bd", "update", beadID, "--description="+newDesc)
|
||||||
|
updateCmd.Stderr = os.Stderr
|
||||||
|
if err := updateCmd.Run(); err != nil {
|
||||||
|
return fmt.Errorf("updating bead description: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// injectStartPrompt sends a prompt to the target pane to start working.
|
// injectStartPrompt sends a prompt to the target pane to start working.
|
||||||
// Uses the reliable nudge pattern: literal mode + 500ms debounce + separate Enter.
|
// Uses the reliable nudge pattern: literal mode + 500ms debounce + separate Enter.
|
||||||
func injectStartPrompt(pane, beadID, subject, args string) error {
|
func injectStartPrompt(pane, beadID, subject, args string) error {
|
||||||
|
|||||||
@@ -703,3 +703,159 @@ func TestLooksLikeBeadID(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestSlingFormulaOnBeadSetsAttachedMolecule verifies that when using
|
||||||
|
// gt sling <formula> --on <bead>, the attached_molecule field is set in the
|
||||||
|
// hooked bead's description after bonding. This is required for gt hook to
|
||||||
|
// recognize the molecule attachment.
|
||||||
|
//
|
||||||
|
// Bug: The original code bonds the wisp to the bead and sets status=hooked,
|
||||||
|
// but doesn't record attached_molecule in the description. This causes
|
||||||
|
// gt hook to report "No molecule attached".
|
||||||
|
func TestSlingFormulaOnBeadSetsAttachedMolecule(t *testing.T) {
|
||||||
|
townRoot := t.TempDir()
|
||||||
|
|
||||||
|
// Minimal workspace marker so workspace.FindFromCwd() succeeds.
|
||||||
|
if err := os.MkdirAll(filepath.Join(townRoot, "mayor", "rig"), 0755); err != nil {
|
||||||
|
t.Fatalf("mkdir mayor/rig: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a rig path that owns gt-* beads, and a routes.jsonl pointing to it.
|
||||||
|
rigDir := filepath.Join(townRoot, "gastown", "mayor", "rig")
|
||||||
|
if err := os.MkdirAll(filepath.Join(townRoot, ".beads"), 0755); err != nil {
|
||||||
|
t.Fatalf("mkdir .beads: %v", err)
|
||||||
|
}
|
||||||
|
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 so we can observe the arguments passed to update 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")
|
||||||
|
bdPath := filepath.Join(binDir, "bd")
|
||||||
|
// The stub logs all commands to a file for verification
|
||||||
|
bdScript := `#!/bin/sh
|
||||||
|
set -e
|
||||||
|
echo "$PWD|$*" >> "${BD_LOG}"
|
||||||
|
if [ "$1" = "--no-daemon" ]; then
|
||||||
|
shift
|
||||||
|
fi
|
||||||
|
cmd="$1"
|
||||||
|
shift || true
|
||||||
|
case "$cmd" in
|
||||||
|
show)
|
||||||
|
echo '[{"title":"Bug to fix","status":"open","assignee":"","description":""}]'
|
||||||
|
;;
|
||||||
|
formula)
|
||||||
|
echo '{"name":"mol-polecat-work"}'
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
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
|
||||||
|
`
|
||||||
|
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", "")
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure we don't leak global flag state across tests.
|
||||||
|
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 bug bead we're applying formula to
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// After bonding (mol bond), there should be an update call that includes
|
||||||
|
// --description with attached_molecule field. This is what gt hook looks for.
|
||||||
|
logLines := strings.Split(string(logBytes), "\n")
|
||||||
|
|
||||||
|
// Find all update commands after the bond
|
||||||
|
sawBond := false
|
||||||
|
foundAttachedMolecule := false
|
||||||
|
for _, line := range logLines {
|
||||||
|
if strings.Contains(line, "mol bond") {
|
||||||
|
sawBond = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if sawBond && strings.Contains(line, "update") {
|
||||||
|
// Check if this update sets attached_molecule in description
|
||||||
|
if strings.Contains(line, "attached_molecule") {
|
||||||
|
foundAttachedMolecule = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !sawBond {
|
||||||
|
t.Fatalf("mol bond command not found in log:\n%s", string(logBytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
if !foundAttachedMolecule {
|
||||||
|
t.Errorf("after mol bond, expected update with attached_molecule in description\n"+
|
||||||
|
"This is required for gt hook to recognize the molecule attachment.\n"+
|
||||||
|
"Log output:\n%s", string(logBytes))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user