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:
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user