fix: gt sling failing to recognize beads after JSONL updates (#290)
* fix(sling): route bd mol commands to target rig directory Executed-By: gastown/crew/jv Rig: gastown Role: crew * Fix CI: enable beads custom types during install Executed-By: gastown/crew/jv Rig: gastown Role: crew * Fix gt sling failing to recognize beads after JSONL updates Executed-By: gastown/crew/jv Rig: gastown Role: crew --------- Co-authored-by: joshuavial <git@codewithjv.com>
This commit is contained in:
@@ -113,9 +113,9 @@ func resolveBeadsDirWithDepth(beadsDir string, maxDepth int) string {
|
||||
// cleanBeadsRuntimeFiles removes gitignored runtime files from a .beads directory
|
||||
// while preserving tracked files (formulas/, README.md, config.yaml, .gitignore).
|
||||
// This is safe to call even if the directory doesn't exist.
|
||||
func cleanBeadsRuntimeFiles(beadsDir string) {
|
||||
func cleanBeadsRuntimeFiles(beadsDir string) error {
|
||||
if _, err := os.Stat(beadsDir); os.IsNotExist(err) {
|
||||
return // Nothing to clean
|
||||
return nil // Nothing to clean
|
||||
}
|
||||
|
||||
// Runtime files/patterns that are gitignored and safe to remove
|
||||
@@ -148,9 +148,13 @@ func cleanBeadsRuntimeFiles(beadsDir string) {
|
||||
continue
|
||||
}
|
||||
for _, match := range matches {
|
||||
_ = os.RemoveAll(match) // Best effort, ignore errors
|
||||
if err := os.RemoveAll(match); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return firstErr
|
||||
}
|
||||
|
||||
// SetupRedirect creates a .beads/redirect file for a worktree to point to the rig's shared beads.
|
||||
@@ -161,10 +165,9 @@ func cleanBeadsRuntimeFiles(beadsDir string) {
|
||||
// - worktreePath: the worktree directory (e.g., <rig>/crew/<name> or <rig>/refinery/rig)
|
||||
//
|
||||
// The function:
|
||||
// 1. Finds the canonical beads location (rig/.beads or mayor/rig/.beads)
|
||||
// 2. Computes the relative path from worktree to that location
|
||||
// 3. Cleans up runtime files (preserving tracked files like formulas/)
|
||||
// 4. Creates the redirect file
|
||||
// 1. Computes the relative path from worktree to rig-level .beads
|
||||
// 2. Cleans up runtime files (preserving tracked files like formulas/)
|
||||
// 3. Creates the redirect file
|
||||
//
|
||||
// Safety: This function refuses to create redirects in the canonical beads location
|
||||
// (mayor/rig) to prevent circular redirect chains.
|
||||
@@ -187,58 +190,42 @@ func SetupRedirect(townRoot, worktreePath string) error {
|
||||
}
|
||||
|
||||
rigRoot := filepath.Join(townRoot, parts[0])
|
||||
|
||||
// Find the canonical beads location. In order of preference:
|
||||
// 1. rig/.beads (if it exists and has content or a redirect)
|
||||
// 2. mayor/rig/.beads (tracked beads architecture)
|
||||
//
|
||||
// The tracked beads architecture stores the actual database in mayor/rig/.beads
|
||||
// and may not have a rig/.beads directory at all.
|
||||
rigBeadsPath := filepath.Join(rigRoot, ".beads")
|
||||
mayorBeadsPath := filepath.Join(rigRoot, "mayor", "rig", ".beads")
|
||||
|
||||
// Compute depth for relative paths
|
||||
// e.g., crew/<name> (depth 2) -> ../../
|
||||
// refinery/rig (depth 2) -> ../../
|
||||
depth := len(parts) - 1 // subtract 1 for rig name itself
|
||||
upPath := strings.Repeat("../", depth)
|
||||
|
||||
var redirectPath string
|
||||
|
||||
// Check if rig-level .beads exists
|
||||
if _, err := os.Stat(rigBeadsPath); err == nil {
|
||||
// rig/.beads exists - check if it has a redirect to follow
|
||||
rigRedirectPath := filepath.Join(rigBeadsPath, "redirect")
|
||||
if data, err := os.ReadFile(rigRedirectPath); err == nil {
|
||||
rigRedirectTarget := strings.TrimSpace(string(data))
|
||||
if rigRedirectTarget != "" {
|
||||
// Rig has redirect (e.g., "mayor/rig/.beads" for tracked beads).
|
||||
// Redirect worktree directly to the final destination.
|
||||
redirectPath = upPath + rigRedirectTarget
|
||||
}
|
||||
}
|
||||
// If no redirect in rig/.beads, point directly to rig/.beads
|
||||
if redirectPath == "" {
|
||||
redirectPath = upPath + ".beads"
|
||||
}
|
||||
} else if _, err := os.Stat(mayorBeadsPath); err == nil {
|
||||
// No rig/.beads but mayor/rig/.beads exists (tracked beads architecture).
|
||||
// Point directly to mayor/rig/.beads.
|
||||
redirectPath = upPath + "mayor/rig/.beads"
|
||||
} else {
|
||||
// Neither location exists - this is an error
|
||||
return fmt.Errorf("no beads found at %s or %s", rigBeadsPath, mayorBeadsPath)
|
||||
if _, err := os.Stat(rigBeadsPath); os.IsNotExist(err) {
|
||||
return fmt.Errorf("no rig .beads found at %s", rigBeadsPath)
|
||||
}
|
||||
|
||||
// Clean up runtime files in .beads/ but preserve tracked files (formulas/, README.md, etc.)
|
||||
worktreeBeadsDir := filepath.Join(worktreePath, ".beads")
|
||||
cleanBeadsRuntimeFiles(worktreeBeadsDir)
|
||||
if err := cleanBeadsRuntimeFiles(worktreeBeadsDir); err != nil {
|
||||
return fmt.Errorf("cleaning runtime files: %w", err)
|
||||
}
|
||||
|
||||
// Create .beads directory if it doesn't exist
|
||||
if err := os.MkdirAll(worktreeBeadsDir, 0755); err != nil {
|
||||
return fmt.Errorf("creating .beads dir: %w", err)
|
||||
}
|
||||
|
||||
// Compute relative path from worktree to rig root
|
||||
// e.g., crew/<name> (depth 2) -> ../../.beads
|
||||
// refinery/rig (depth 2) -> ../../.beads
|
||||
depth := len(parts) - 1 // subtract 1 for rig name itself
|
||||
redirectPath := strings.Repeat("../", depth) + ".beads"
|
||||
|
||||
// Check if rig-level beads has a redirect (tracked beads case).
|
||||
// If so, redirect directly to the final destination to avoid chains.
|
||||
// The bd CLI doesn't support redirect chains, so we must skip intermediate hops.
|
||||
rigRedirectPath := filepath.Join(rigBeadsPath, "redirect")
|
||||
if data, err := os.ReadFile(rigRedirectPath); err == nil {
|
||||
rigRedirectTarget := strings.TrimSpace(string(data))
|
||||
if rigRedirectTarget != "" {
|
||||
// Rig has redirect (e.g., "mayor/rig/.beads" for tracked beads).
|
||||
// Redirect worktree directly to the final destination.
|
||||
redirectPath = strings.Repeat("../", depth) + rigRedirectTarget
|
||||
}
|
||||
}
|
||||
|
||||
// Create redirect file
|
||||
redirectFile := filepath.Join(worktreeBeadsDir, "redirect")
|
||||
if err := os.WriteFile(redirectFile, []byte(redirectPath+"\n"), 0644); err != nil {
|
||||
|
||||
@@ -362,14 +362,6 @@ func initTownBeads(townPath string) error {
|
||||
fmt.Printf(" %s Could not verify repo fingerprint: %v\n", style.Dim.Render("⚠"), err)
|
||||
}
|
||||
|
||||
// Register Gas Town custom types (agent, role, rig, convoy, slot).
|
||||
// These types are not built into beads core - they must be registered
|
||||
// before creating agent/role beads. See gt-4ke5e for context.
|
||||
if err := ensureCustomTypes(townPath); err != nil {
|
||||
// Non-fatal but will cause agent bead creation to fail
|
||||
fmt.Printf(" %s Could not register custom types: %v\n", style.Dim.Render("⚠"), err)
|
||||
}
|
||||
|
||||
// Ensure routes.jsonl has an explicit town-level mapping for hq-* beads.
|
||||
// This keeps hq-* operations stable even when invoked from rig worktrees.
|
||||
if err := beads.AppendRoute(townPath, beads.Route{Prefix: "hq-", Path: "."}); err != nil {
|
||||
|
||||
@@ -511,7 +511,7 @@ func runSling(cmd *cobra.Command, args []string) error {
|
||||
// This enables no-tmux mode where agents discover args via gt prime / bd show.
|
||||
func storeArgsInBead(beadID, args string) error {
|
||||
// Get the bead to preserve existing description content
|
||||
showCmd := exec.Command("bd", "--no-daemon", "show", beadID, "--json")
|
||||
showCmd := exec.Command("bd", "--no-daemon", "show", beadID, "--json", "--allow-stale")
|
||||
out, err := showCmd.Output()
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetching bead: %w", err)
|
||||
@@ -720,8 +720,12 @@ func sessionToAgentID(sessionName string) string {
|
||||
// verifyBeadExists checks that the bead exists using bd show.
|
||||
// Uses bd's native prefix-based routing via routes.jsonl - do NOT set BEADS_DIR
|
||||
// as that overrides routing and breaks resolution of rig-level beads.
|
||||
//
|
||||
// Uses --no-daemon with --allow-stale to avoid daemon socket timing issues
|
||||
// while still finding beads when database is out of sync with JSONL.
|
||||
// For existence checks, stale data is acceptable - we just need to know it exists.
|
||||
func verifyBeadExists(beadID string) error {
|
||||
cmd := exec.Command("bd", "--no-daemon", "show", beadID, "--json")
|
||||
cmd := exec.Command("bd", "--no-daemon", "show", beadID, "--json", "--allow-stale")
|
||||
// Run from town root so bd can find routes.jsonl for prefix-based routing.
|
||||
// Do NOT set BEADS_DIR - that overrides routing and breaks rig bead resolution.
|
||||
if townRoot, err := workspace.FindFromCwd(); err == nil {
|
||||
@@ -742,8 +746,9 @@ type beadInfo struct {
|
||||
|
||||
// getBeadInfo returns status and assignee for a bead.
|
||||
// Uses bd's native prefix-based routing via routes.jsonl.
|
||||
// Uses --no-daemon with --allow-stale for consistency with verifyBeadExists.
|
||||
func getBeadInfo(beadID string) (*beadInfo, error) {
|
||||
cmd := exec.Command("bd", "--no-daemon", "show", beadID, "--json")
|
||||
cmd := exec.Command("bd", "--no-daemon", "show", beadID, "--json", "--allow-stale")
|
||||
// Run from town root so bd can find routes.jsonl for prefix-based routing.
|
||||
if townRoot, err := workspace.FindFromCwd(); err == nil {
|
||||
cmd.Dir = townRoot
|
||||
@@ -814,15 +819,16 @@ func resolveSelfTarget() (agentID string, pane string, hookRoot string, err erro
|
||||
|
||||
// verifyFormulaExists checks that the formula exists using bd formula show.
|
||||
// Formulas are TOML files (.formula.toml).
|
||||
// Uses --no-daemon with --allow-stale for consistency with verifyBeadExists.
|
||||
func verifyFormulaExists(formulaName string) error {
|
||||
// Try bd formula show (handles all formula file formats)
|
||||
cmd := exec.Command("bd", "--no-daemon", "formula", "show", formulaName)
|
||||
cmd := exec.Command("bd", "--no-daemon", "formula", "show", formulaName, "--allow-stale")
|
||||
if err := cmd.Run(); err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Try with mol- prefix
|
||||
cmd = exec.Command("bd", "--no-daemon", "formula", "show", "mol-"+formulaName)
|
||||
cmd = exec.Command("bd", "--no-daemon", "formula", "show", "mol-"+formulaName, "--allow-stale")
|
||||
if err := cmd.Run(); err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -343,3 +343,163 @@ exit 0
|
||||
t.Fatalf("missing expected bd commands: cook=%v wisp=%v bond=%v (log: %q)", gotCook, gotWisp, gotBond, string(logBytes))
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerifyBeadExistsAllowStale reproduces the bug in gtl-ncq where beads
|
||||
// visible via regular bd show fail with --no-daemon due to database sync issues.
|
||||
// The fix uses --allow-stale to skip the sync check for existence verification.
|
||||
func TestVerifyBeadExistsAllowStale(t *testing.T) {
|
||||
townRoot := t.TempDir()
|
||||
|
||||
// Create minimal workspace structure
|
||||
if err := os.MkdirAll(filepath.Join(townRoot, "mayor", "rig"), 0755); err != nil {
|
||||
t.Fatalf("mkdir mayor/rig: %v", err)
|
||||
}
|
||||
|
||||
// Create a stub bd that simulates the sync issue:
|
||||
// - --no-daemon without --allow-stale fails (database out of sync)
|
||||
// - --no-daemon with --allow-stale succeeds (skips sync check)
|
||||
binDir := filepath.Join(townRoot, "bin")
|
||||
if err := os.MkdirAll(binDir, 0755); err != nil {
|
||||
t.Fatalf("mkdir binDir: %v", err)
|
||||
}
|
||||
bdPath := filepath.Join(binDir, "bd")
|
||||
bdScript := `#!/bin/sh
|
||||
# Check for --allow-stale flag
|
||||
allow_stale=false
|
||||
for arg in "$@"; do
|
||||
if [ "$arg" = "--allow-stale" ]; then
|
||||
allow_stale=true
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$1" = "--no-daemon" ]; then
|
||||
if [ "$allow_stale" = "true" ]; then
|
||||
# --allow-stale skips sync check, succeeds
|
||||
echo '[{"title":"Test bead","status":"open","assignee":""}]'
|
||||
exit 0
|
||||
else
|
||||
# Without --allow-stale, fails with sync error
|
||||
echo '{"error":"Database out of sync with JSONL."}'
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
# Daemon mode works
|
||||
echo '[{"title":"Test bead","status":"open","assignee":""}]'
|
||||
exit 0
|
||||
`
|
||||
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"))
|
||||
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("getwd: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(cwd) })
|
||||
if err := os.Chdir(townRoot); err != nil {
|
||||
t.Fatalf("chdir: %v", err)
|
||||
}
|
||||
|
||||
// EXPECTED: verifyBeadExists should use --no-daemon --allow-stale and succeed
|
||||
beadID := "jv-v599"
|
||||
err = verifyBeadExists(beadID)
|
||||
if err != nil {
|
||||
t.Errorf("verifyBeadExists(%q) failed: %v\nExpected --allow-stale to skip sync check", beadID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSlingWithAllowStale tests the full gt sling flow with --allow-stale fix.
|
||||
// This is an integration test for the gtl-ncq bug.
|
||||
func TestSlingWithAllowStale(t *testing.T) {
|
||||
townRoot := t.TempDir()
|
||||
|
||||
// Create minimal workspace structure
|
||||
if err := os.MkdirAll(filepath.Join(townRoot, "mayor", "rig"), 0755); err != nil {
|
||||
t.Fatalf("mkdir mayor/rig: %v", err)
|
||||
}
|
||||
|
||||
// Create stub bd that respects --allow-stale
|
||||
binDir := filepath.Join(townRoot, "bin")
|
||||
if err := os.MkdirAll(binDir, 0755); err != nil {
|
||||
t.Fatalf("mkdir binDir: %v", err)
|
||||
}
|
||||
bdPath := filepath.Join(binDir, "bd")
|
||||
bdScript := `#!/bin/sh
|
||||
# Check for --allow-stale flag
|
||||
allow_stale=false
|
||||
for arg in "$@"; do
|
||||
if [ "$arg" = "--allow-stale" ]; then
|
||||
allow_stale=true
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$1" = "--no-daemon" ]; then
|
||||
shift
|
||||
cmd="$1"
|
||||
if [ "$cmd" = "show" ]; then
|
||||
if [ "$allow_stale" = "true" ]; then
|
||||
echo '[{"title":"Synced bead","status":"open","assignee":""}]'
|
||||
exit 0
|
||||
fi
|
||||
echo '{"error":"Database out of sync"}'
|
||||
exit 1
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
cmd="$1"
|
||||
shift || true
|
||||
case "$cmd" in
|
||||
show)
|
||||
echo '[{"title":"Synced bead","status":"open","assignee":""}]'
|
||||
;;
|
||||
update)
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
exit 0
|
||||
`
|
||||
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(EnvGTRole, "crew")
|
||||
t.Setenv("GT_CREW", "jv")
|
||||
t.Setenv("GT_POLECAT", "")
|
||||
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("getwd: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(cwd) })
|
||||
if err := os.Chdir(townRoot); err != nil {
|
||||
t.Fatalf("chdir: %v", err)
|
||||
}
|
||||
|
||||
// Save and restore global flags
|
||||
prevDryRun := slingDryRun
|
||||
prevNoConvoy := slingNoConvoy
|
||||
t.Cleanup(func() {
|
||||
slingDryRun = prevDryRun
|
||||
slingNoConvoy = prevNoConvoy
|
||||
})
|
||||
|
||||
slingDryRun = true
|
||||
slingNoConvoy = true
|
||||
|
||||
// EXPECTED: gt sling should use daemon mode and succeed
|
||||
// ACTUAL: verifyBeadExists uses --no-daemon and fails with sync error
|
||||
beadID := "jv-v599"
|
||||
err = runSling(nil, []string{beadID})
|
||||
if err != nil {
|
||||
// Check if it's the specific error we're testing for
|
||||
if strings.Contains(err.Error(), "is not a valid bead or formula") {
|
||||
t.Errorf("gt sling failed to recognize bead %q: %v\nExpected to use daemon mode, but used --no-daemon which fails when DB out of sync", beadID, err)
|
||||
} else {
|
||||
// Some other error - might be expected in dry-run mode
|
||||
t.Logf("gt sling returned error (may be expected in test): %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user