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
477 lines
13 KiB
Go
477 lines
13 KiB
Go
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)
|
|
}
|
|
}
|