fix: prevent inherited BEADS_DIR from causing prefix mismatch (#321)

- Fix beads.run() to always explicitly set BEADS_DIR based on the working
  directory or explicit override
- This prevents inherited environment variables (e.g., from mayor session
  with BEADS_DIR=/home/erik/gt/.beads) from causing prefix mismatch errors
  when creating agent beads for rigs
- Update polecat manager to use NewWithBeadsDir for explicitness
- Add comprehensive test coverage for BEADS_DIR routing and validation
- Add SessionLister interface for deterministic orphan session testing

Root cause: When BEADS_DIR was set in the parent environment, all bd
commands used the town database (hq- prefix) instead of the rig database
(gt- prefix), causing "prefix mismatch: database uses 'hq' but you
specified 'gt'" errors during polecat spawn.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
gastown/crew/joe
2026-01-11 18:33:34 -08:00
committed by Steve Yegge
parent ea84079f8b
commit 598a39e708
7 changed files with 1298 additions and 83 deletions

View File

@@ -6,10 +6,10 @@
package cmd
import (
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"github.com/steveyegge/gastown/internal/beads"
@@ -104,6 +104,50 @@ func setupRoutingTestTown(t *testing.T) string {
return townRoot
}
func initBeadsDBWithPrefix(t *testing.T, dir, prefix string) {
t.Helper()
cmd := exec.Command("bd", "--no-daemon", "init", "--quiet", "--prefix", prefix)
cmd.Dir = dir
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("bd init failed in %s: %v\n%s", dir, err, output)
}
}
func createTestIssue(t *testing.T, dir, title string) *beads.Issue {
t.Helper()
args := []string{"--no-daemon", "create", "--json", "--title", title, "--type", "task",
"--description", "Integration test issue"}
cmd := exec.Command("bd", args...)
cmd.Dir = dir
output, err := cmd.Output()
if err != nil {
combinedCmd := exec.Command("bd", args...)
combinedCmd.Dir = dir
combinedOutput, _ := combinedCmd.CombinedOutput()
t.Fatalf("create issue in %s: %v\n%s", dir, err, combinedOutput)
}
var issue beads.Issue
if err := json.Unmarshal(output, &issue); err != nil {
t.Fatalf("parse create output in %s: %v", dir, err)
}
if issue.ID == "" {
t.Fatalf("create issue in %s returned empty ID", dir)
}
return &issue
}
func hasIssueID(issues []*beads.Issue, id string) bool {
for _, issue := range issues {
if issue.ID == id {
return true
}
}
return false
}
// TestBeadsRoutingFromTownRoot verifies that bd show routes to correct rig
// based on issue ID prefix when run from town root.
func TestBeadsRoutingFromTownRoot(t *testing.T) {
@@ -114,37 +158,38 @@ func TestBeadsRoutingFromTownRoot(t *testing.T) {
townRoot := setupRoutingTestTown(t)
initBeadsDBWithPrefix(t, townRoot, "hq")
gastownRigPath := filepath.Join(townRoot, "gastown", "mayor", "rig")
testrigRigPath := filepath.Join(townRoot, "testrig", "mayor", "rig")
initBeadsDBWithPrefix(t, gastownRigPath, "gt")
initBeadsDBWithPrefix(t, testrigRigPath, "tr")
townIssue := createTestIssue(t, townRoot, "Town-level routing test")
gastownIssue := createTestIssue(t, gastownRigPath, "Gastown routing test")
testrigIssue := createTestIssue(t, testrigRigPath, "Testrig routing test")
tests := []struct {
prefix string
expectedRig string // Expected rig path fragment in error/output
id string
title string
}{
{"hq-", "."}, // Town-level beads
{"gt-", "gastown"},
{"tr-", "testrig"},
{townIssue.ID, townIssue.Title},
{gastownIssue.ID, gastownIssue.Title},
{testrigIssue.ID, testrigIssue.Title},
}
townBeads := beads.New(townRoot)
for _, tc := range tests {
t.Run(tc.prefix, func(t *testing.T) {
// Create a fake issue ID with the prefix
issueID := tc.prefix + "test123"
// Run bd show - it will fail since issue doesn't exist,
// but we're testing routing, not the issue itself
cmd := exec.Command("bd", "--no-daemon", "show", issueID)
cmd.Dir = townRoot
cmd.Env = append(os.Environ(), "BD_DEBUG_ROUTING=1")
output, _ := cmd.CombinedOutput()
// The debug routing output or error message should indicate
// which beads directory was used
outputStr := string(output)
t.Logf("Output for %s: %s", issueID, outputStr)
// We expect either the routing debug output or an error from the correct beads
// If routing works, the error will be about not finding the issue,
// not about routing failure
if strings.Contains(outputStr, "no matching route") {
t.Errorf("routing failed for prefix %s: %s", tc.prefix, outputStr)
t.Run(tc.id, func(t *testing.T) {
issue, err := townBeads.Show(tc.id)
if err != nil {
t.Fatalf("bd show %s failed: %v", tc.id, err)
}
if issue.ID != tc.id {
t.Errorf("issue.ID = %s, want %s", issue.ID, tc.id)
}
if issue.Title != tc.title {
t.Errorf("issue.Title = %q, want %q", issue.Title, tc.title)
}
})
}
@@ -263,30 +308,21 @@ func TestBeadsListFromPolecatDirectory(t *testing.T) {
townRoot := setupRoutingTestTown(t)
polecatDir := filepath.Join(townRoot, "gastown", "polecats", "rictus")
// Initialize beads in mayor/rig so bd list can work
mayorRigBeads := filepath.Join(townRoot, "gastown", "mayor", "rig", ".beads")
rigPath := filepath.Join(townRoot, "gastown", "mayor", "rig")
initBeadsDBWithPrefix(t, rigPath, "gt")
// Create a minimal beads.db (or use bd init)
// For now, just test that the redirect is followed
cmd := exec.Command("bd", "--no-daemon", "list")
cmd.Dir = polecatDir
output, err := cmd.CombinedOutput()
// We expect either success (empty list) or an error about missing db,
// but NOT an error about missing .beads directory (since redirect should work)
outputStr := string(output)
t.Logf("bd list output: %s", outputStr)
issue := createTestIssue(t, rigPath, "Polecat list redirect test")
issues, err := beads.New(polecatDir).List(beads.ListOptions{
Status: "open",
Priority: -1,
})
if err != nil {
// Check it's not a "no .beads directory" error
if strings.Contains(outputStr, "no .beads directory") {
t.Errorf("redirect not followed: %s", outputStr)
}
// Check it's finding the right beads directory via redirect
if strings.Contains(outputStr, "redirect") && !strings.Contains(outputStr, mayorRigBeads) {
// This is okay - the redirect is being processed
t.Logf("redirect detected in output (expected)")
}
t.Fatalf("bd list from polecat dir failed: %v", err)
}
if !hasIssueID(issues, issue.ID) {
t.Errorf("bd list from polecat dir missing issue %s", issue.ID)
}
}
@@ -300,18 +336,20 @@ func TestBeadsListFromCrewDirectory(t *testing.T) {
townRoot := setupRoutingTestTown(t)
crewDir := filepath.Join(townRoot, "gastown", "crew", "max")
cmd := exec.Command("bd", "--no-daemon", "list")
cmd.Dir = crewDir
output, err := cmd.CombinedOutput()
rigPath := filepath.Join(townRoot, "gastown", "mayor", "rig")
initBeadsDBWithPrefix(t, rigPath, "gt")
outputStr := string(output)
t.Logf("bd list output from crew: %s", outputStr)
issue := createTestIssue(t, rigPath, "Crew list redirect test")
issues, err := beads.New(crewDir).List(beads.ListOptions{
Status: "open",
Priority: -1,
})
if err != nil {
// Check it's not a "no .beads directory" error
if strings.Contains(outputStr, "no .beads directory") {
t.Errorf("redirect not followed for crew: %s", outputStr)
}
t.Fatalf("bd list from crew dir failed: %v", err)
}
if !hasIssueID(issues, issue.ID) {
t.Errorf("bd list from crew dir missing issue %s", issue.ID)
}
}