From 9cb14cc41a385f760dff445a55987196d283cab7 Mon Sep 17 00:00:00 2001 From: mayor Date: Mon, 5 Jan 2026 08:32:42 -0500 Subject: [PATCH] fix(sling): resolve rig path for cross-rig bead hooking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gt sling failed when hooking rig-level beads from town root because bd update doesn't support cross-database routing like bd show does. The fix adds a ResolveHookDir helper that: 1. Extracts the prefix from bead ID (e.g., "ap-xxx" → "ap-") 2. Looks up the rig path from routes.jsonl 3. Falls back to townRoot if prefix not found Also removes the BEADS_DIR environment override which was preventing routing from working correctly. Fixes #148 --- internal/beads/routes.go | 57 ++++++++ internal/beads/routes_test.go | 137 ++++++++++++++++++ .../cmd/beads_routing_integration_test.go | 67 +++++++++ internal/cmd/sling.go | 36 ++--- 4 files changed, 270 insertions(+), 27 deletions(-) diff --git a/internal/beads/routes.go b/internal/beads/routes.go index cd5c2812..1afb51a2 100644 --- a/internal/beads/routes.go +++ b/internal/beads/routes.go @@ -190,3 +190,60 @@ func FindConflictingPrefixes(beadsDir string) (map[string][]string, error) { return conflicts, nil } + +// ExtractPrefix extracts the prefix from a bead ID. +// For example, "ap-qtsup.16" returns "ap-", "hq-cv-abc" returns "hq-". +// Returns empty string if no valid prefix found (empty input, no hyphen, +// or hyphen at position 0 which would indicate an invalid prefix). +func ExtractPrefix(beadID string) string { + if beadID == "" { + return "" + } + + idx := strings.Index(beadID, "-") + if idx <= 0 { + return "" + } + + return beadID[:idx+1] +} + +// GetRigPathForPrefix returns the rig path for a given bead ID prefix. +// The townRoot should be the Gas Town root directory (e.g., ~/gt). +// Returns the full absolute path to the rig directory, or empty string if not found. +// For town-level beads (path="."), returns townRoot. +func GetRigPathForPrefix(townRoot, prefix string) string { + beadsDir := filepath.Join(townRoot, ".beads") + routes, err := LoadRoutes(beadsDir) + if err != nil || routes == nil { + return "" + } + + for _, r := range routes { + if r.Prefix == prefix { + if r.Path == "." { + return townRoot // Town-level beads + } + return filepath.Join(townRoot, r.Path) + } + } + + return "" +} + +// ResolveHookDir determines the directory for running bd update on a bead. +// Since bd update doesn't support routing or redirects, we must resolve the +// actual rig directory from the bead's prefix. hookWorkDir is only used as +// a fallback if prefix resolution fails. +func ResolveHookDir(townRoot, beadID, hookWorkDir string) string { + // Always try prefix resolution first - bd update needs the actual rig dir + prefix := ExtractPrefix(beadID) + if rigPath := GetRigPathForPrefix(townRoot, prefix); rigPath != "" { + return rigPath + } + // Fallback to hookWorkDir if provided + if hookWorkDir != "" { + return hookWorkDir + } + return townRoot +} diff --git a/internal/beads/routes_test.go b/internal/beads/routes_test.go index 06561dc2..94f7a5f9 100644 --- a/internal/beads/routes_test.go +++ b/internal/beads/routes_test.go @@ -52,6 +52,143 @@ func TestGetPrefixForRig_NoRoutesFile(t *testing.T) { } } +func TestExtractPrefix(t *testing.T) { + tests := []struct { + beadID string + expected string + }{ + {"ap-qtsup.16", "ap-"}, + {"hq-cv-abc", "hq-"}, + {"gt-mol-xyz", "gt-"}, + {"bd-123", "bd-"}, + {"", ""}, + {"nohyphen", ""}, + {"-startswithhyphen", ""}, // Leading hyphen = invalid prefix + {"-", ""}, // Just hyphen = invalid + {"a-", "a-"}, // Trailing hyphen is valid + } + + for _, tc := range tests { + t.Run(tc.beadID, func(t *testing.T) { + result := ExtractPrefix(tc.beadID) + if result != tc.expected { + t.Errorf("ExtractPrefix(%q) = %q, want %q", tc.beadID, result, tc.expected) + } + }) + } +} + +func TestGetRigPathForPrefix(t *testing.T) { + // Create a temporary directory with routes.jsonl + tmpDir := t.TempDir() + beadsDir := filepath.Join(tmpDir, ".beads") + if err := os.MkdirAll(beadsDir, 0755); err != nil { + t.Fatal(err) + } + + routesContent := `{"prefix": "ap-", "path": "ai_platform/mayor/rig"} +{"prefix": "gt-", "path": "gastown/mayor/rig"} +{"prefix": "hq-", "path": "."} +` + if err := os.WriteFile(filepath.Join(beadsDir, "routes.jsonl"), []byte(routesContent), 0644); err != nil { + t.Fatal(err) + } + + tests := []struct { + prefix string + expected string + }{ + {"ap-", filepath.Join(tmpDir, "ai_platform/mayor/rig")}, + {"gt-", filepath.Join(tmpDir, "gastown/mayor/rig")}, + {"hq-", tmpDir}, // Town-level beads return townRoot + {"unknown-", ""}, // Unknown prefix returns empty + {"", ""}, // Empty prefix returns empty + } + + for _, tc := range tests { + t.Run(tc.prefix, func(t *testing.T) { + result := GetRigPathForPrefix(tmpDir, tc.prefix) + if result != tc.expected { + t.Errorf("GetRigPathForPrefix(%q, %q) = %q, want %q", tmpDir, tc.prefix, result, tc.expected) + } + }) + } +} + +func TestGetRigPathForPrefix_NoRoutesFile(t *testing.T) { + tmpDir := t.TempDir() + // No routes.jsonl file + + result := GetRigPathForPrefix(tmpDir, "ap-") + if result != "" { + t.Errorf("Expected empty string when no routes file, got %q", result) + } +} + +func TestResolveHookDir(t *testing.T) { + // Create a temporary directory with routes.jsonl + tmpDir := t.TempDir() + beadsDir := filepath.Join(tmpDir, ".beads") + if err := os.MkdirAll(beadsDir, 0755); err != nil { + t.Fatal(err) + } + + routesContent := `{"prefix": "ap-", "path": "ai_platform/mayor/rig"} +{"prefix": "hq-", "path": "."} +` + if err := os.WriteFile(filepath.Join(beadsDir, "routes.jsonl"), []byte(routesContent), 0644); err != nil { + t.Fatal(err) + } + + tests := []struct { + name string + beadID string + hookWorkDir string + expected string + }{ + { + name: "prefix resolution takes precedence over hookWorkDir", + beadID: "ap-test", + hookWorkDir: "/custom/path", + expected: filepath.Join(tmpDir, "ai_platform/mayor/rig"), + }, + { + name: "resolves rig path from prefix", + beadID: "ap-test", + hookWorkDir: "", + expected: filepath.Join(tmpDir, "ai_platform/mayor/rig"), + }, + { + name: "town-level bead returns townRoot", + beadID: "hq-test", + hookWorkDir: "", + expected: tmpDir, + }, + { + name: "unknown prefix uses hookWorkDir as fallback", + beadID: "xx-unknown", + hookWorkDir: "/fallback/path", + expected: "/fallback/path", + }, + { + name: "unknown prefix without hookWorkDir falls back to townRoot", + beadID: "xx-unknown", + hookWorkDir: "", + expected: tmpDir, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := ResolveHookDir(tmpDir, tc.beadID, tc.hookWorkDir) + if result != tc.expected { + t.Errorf("ResolveHookDir(%q, %q, %q) = %q, want %q", + tmpDir, tc.beadID, tc.hookWorkDir, result, tc.expected) + } + }) + } +} + func TestAgentBeadIDsWithPrefix(t *testing.T) { tests := []struct { name string diff --git a/internal/cmd/beads_routing_integration_test.go b/internal/cmd/beads_routing_integration_test.go index 114e6676..cb6307a1 100644 --- a/internal/cmd/beads_routing_integration_test.go +++ b/internal/cmd/beads_routing_integration_test.go @@ -443,6 +443,73 @@ func TestBeadsRemoveRoute(t *testing.T) { } } +// TestSlingCrossRigRoutingResolution verifies that sling can resolve rig paths +// for cross-rig bead hooking using ExtractPrefix and GetRigPathForPrefix. +// This is the fix for https://github.com/steveyegge/gastown/issues/148 +func TestSlingCrossRigRoutingResolution(t *testing.T) { + townRoot := setupRoutingTestTown(t) + + tests := []struct { + beadID string + expectedPath string // Relative to townRoot, or "." for town-level + }{ + {"gt-mol-abc", "gastown/mayor/rig"}, + {"tr-task-xyz", "testrig/mayor/rig"}, + {"hq-cv-123", "."}, // Town-level beads + } + + for _, tc := range tests { + t.Run(tc.beadID, func(t *testing.T) { + // Step 1: Extract prefix from bead ID + prefix := beads.ExtractPrefix(tc.beadID) + if prefix == "" { + t.Fatalf("ExtractPrefix(%q) returned empty", tc.beadID) + } + + // Step 2: Resolve rig path from prefix + rigPath := beads.GetRigPathForPrefix(townRoot, prefix) + if rigPath == "" { + t.Fatalf("GetRigPathForPrefix(%q, %q) returned empty", townRoot, prefix) + } + + // Step 3: Verify the path is correct + var expectedFull string + if tc.expectedPath == "." { + expectedFull = townRoot + } else { + expectedFull = filepath.Join(townRoot, tc.expectedPath) + } + + if rigPath != expectedFull { + t.Errorf("GetRigPathForPrefix resolved to %q, want %q", rigPath, expectedFull) + } + + // Step 4: Verify the .beads directory exists at that path + beadsDir := filepath.Join(rigPath, ".beads") + if _, err := os.Stat(beadsDir); os.IsNotExist(err) { + t.Errorf(".beads directory doesn't exist at resolved path: %s", beadsDir) + } + }) + } +} + +// TestSlingCrossRigUnknownPrefix verifies behavior for unknown prefixes. +func TestSlingCrossRigUnknownPrefix(t *testing.T) { + townRoot := setupRoutingTestTown(t) + + // An unknown prefix should return empty string + unknownBeadID := "xx-unknown-123" + prefix := beads.ExtractPrefix(unknownBeadID) + if prefix != "xx-" { + t.Fatalf("ExtractPrefix(%q) = %q, want %q", unknownBeadID, prefix, "xx-") + } + + rigPath := beads.GetRigPathForPrefix(townRoot, prefix) + if rigPath != "" { + t.Errorf("GetRigPathForPrefix for unknown prefix returned %q, want empty", rigPath) + } +} + // TestBeadsGetPrefixForRig verifies prefix lookup by rig name. func TestBeadsGetPrefixForRig(t *testing.T) { tmpDir := t.TempDir() diff --git a/internal/cmd/sling.go b/internal/cmd/sling.go index 5dd8372d..2d211613 100644 --- a/internal/cmd/sling.go +++ b/internal/cmd/sling.go @@ -406,20 +406,10 @@ func runSling(cmd *cobra.Command, args []string) error { beadID = wispRootID } - // Hook the bead using bd update - // For town-level beads (hq-*), set BEADS_DIR to town beads - // For rig-level beads (gt-*, bd-*, etc.), use redirect-based routing from hookWorkDir + // Hook the bead using bd update. + // See: https://github.com/steveyegge/gastown/issues/148 hookCmd := exec.Command("bd", "--no-daemon", "update", beadID, "--status=hooked", "--assignee="+targetAgent) - if strings.HasPrefix(beadID, "hq-") { - // Town-level bead: set BEADS_DIR explicitly - hookCmd.Env = append(os.Environ(), "BEADS_DIR="+townBeadsDir) - hookCmd.Dir = townRoot - } else if hookWorkDir != "" { - // Rig-level bead: use redirect from polecat's worktree - hookCmd.Dir = hookWorkDir - } else { - hookCmd.Dir = townRoot - } + hookCmd.Dir = beads.ResolveHookDir(townRoot, beadID, hookWorkDir) hookCmd.Stderr = os.Stderr if err := hookCmd.Run(); err != nil { return fmt.Errorf("hooking bead: %w", err) @@ -939,11 +929,10 @@ func runSlingFormula(args []string) error { fmt.Printf("%s Wisp created: %s\n", style.Bold.Render("✓"), wispResult.RootID) - // Step 3: Hook the wisp bead using bd update (discovery-based approach) - // Set BEADS_DIR to town-level beads so hq-* beads are accessible + // Step 3: Hook the wisp bead using bd update. + // See: https://github.com/steveyegge/gastown/issues/148 hookCmd := exec.Command("bd", "--no-daemon", "update", wispResult.RootID, "--status=hooked", "--assignee="+targetAgent) - hookCmd.Env = append(os.Environ(), "BEADS_DIR="+townBeadsDir) - hookCmd.Dir = townRoot + hookCmd.Dir = beads.ResolveHookDir(townRoot, wispResult.RootID, "") hookCmd.Stderr = os.Stderr if err := hookCmd.Run(); err != nil { return fmt.Errorf("hooking wisp bead: %w", err) @@ -1432,17 +1421,10 @@ func runBatchSling(beadIDs []string, rigName string, townBeadsDir string) error } } - // Hook the bead - // For town-level beads (hq-*), set BEADS_DIR; for rig-level beads use redirect + // 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) - if strings.HasPrefix(beadID, "hq-") { - // Town-level bead: set BEADS_DIR and run from town root (parent of townBeadsDir) - hookCmd.Env = append(os.Environ(), "BEADS_DIR="+townBeadsDir) - hookCmd.Dir = filepath.Dir(townBeadsDir) - } else if hookWorkDir != "" { - // Rig-level bead: use redirect from polecat's worktree - hookCmd.Dir = hookWorkDir - } + hookCmd.Dir = beads.ResolveHookDir(townRoot, beadID, 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"})