fix(beads): multi-repo routing for custom types and role slots
Fixes two bugs in multi-repo routing scenarios: 1. "invalid issue type: agent" error when creating agent beads - Added EnsureCustomTypes() with two-level caching (in-memory + sentinel file) - CreateAgentBead() now resolves routing target and ensures custom types 2. "could not set role slot: issue not found" warning when setting slots - Added runSlotSet() and runSlotClear() helpers that run bd from correct directory - Slot operations now use the resolved target directory New files: - internal/beads/beads_types.go - routing resolution and custom types logic - internal/beads/beads_types_test.go - unit tests Based on PR #811 by Perttulands, rebased onto current main. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
committed by
beads/crew/emma
parent
b2b9cbc836
commit
fa1f812ce9
@@ -114,6 +114,11 @@ type Beads struct {
|
||||
workDir string
|
||||
beadsDir string // Optional BEADS_DIR override for cross-database access
|
||||
isolated bool // If true, suppress inherited beads env vars (for test isolation)
|
||||
|
||||
// Lazy-cached town root for routing resolution.
|
||||
// Populated on first call to getTownRoot() to avoid filesystem walk on every operation.
|
||||
townRoot string
|
||||
searchedRoot bool
|
||||
}
|
||||
|
||||
// New creates a new Beads wrapper for the given directory.
|
||||
@@ -144,6 +149,26 @@ func (b *Beads) getActor() string {
|
||||
return os.Getenv("BD_ACTOR")
|
||||
}
|
||||
|
||||
// getTownRoot returns the Gas Town root directory, using lazy caching.
|
||||
// The town root is found by walking up from workDir looking for mayor/town.json.
|
||||
// Returns empty string if not in a Gas Town project.
|
||||
func (b *Beads) getTownRoot() string {
|
||||
if !b.searchedRoot {
|
||||
b.townRoot = FindTownRoot(b.workDir)
|
||||
b.searchedRoot = true
|
||||
}
|
||||
return b.townRoot
|
||||
}
|
||||
|
||||
// getResolvedBeadsDir returns the beads directory this wrapper is operating on.
|
||||
// This follows any redirects and returns the actual beads directory path.
|
||||
func (b *Beads) getResolvedBeadsDir() string {
|
||||
if b.beadsDir != "" {
|
||||
return b.beadsDir
|
||||
}
|
||||
return ResolveBeadsDir(b.workDir)
|
||||
}
|
||||
|
||||
// Init initializes a new beads database in the working directory.
|
||||
// This uses the same environment isolation as other commands.
|
||||
func (b *Beads) Init(prefix string) error {
|
||||
|
||||
@@ -5,9 +5,32 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// runSlotSet runs `bd slot set` from a specific directory.
|
||||
// This is needed when the agent bead was created via routing to a different
|
||||
// database than the Beads wrapper's default directory.
|
||||
func runSlotSet(workDir, beadID, slotName, slotValue string) error {
|
||||
cmd := exec.Command("bd", "slot", "set", beadID, slotName, slotValue)
|
||||
cmd.Dir = workDir
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("%s: %w", strings.TrimSpace(string(output)), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// runSlotClear runs `bd slot clear` from a specific directory.
|
||||
func runSlotClear(workDir, beadID, slotName string) error {
|
||||
cmd := exec.Command("bd", "slot", "clear", beadID, slotName)
|
||||
cmd.Dir = workDir
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("%s: %w", strings.TrimSpace(string(output)), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AgentFields holds structured fields for agent beads.
|
||||
// These are stored as "key: value" lines in the description.
|
||||
type AgentFields struct {
|
||||
@@ -125,7 +148,21 @@ func ParseAgentFields(description string) *AgentFields {
|
||||
// The ID format is: <prefix>-<rig>-<role>-<name> (e.g., gt-gastown-polecat-Toast)
|
||||
// Use AgentBeadID() helper to generate correct IDs.
|
||||
// The created_by field is populated from BD_ACTOR env var for provenance tracking.
|
||||
//
|
||||
// This function automatically ensures custom types are configured in the target
|
||||
// database before creating the bead. This handles multi-repo routing scenarios
|
||||
// where the bead may be routed to a different database than the one this wrapper
|
||||
// is connected to.
|
||||
func (b *Beads) CreateAgentBead(id, title string, fields *AgentFields) (*Issue, error) {
|
||||
// Resolve where this bead will actually be written (handles multi-repo routing)
|
||||
targetDir := ResolveRoutingTarget(b.getTownRoot(), id, b.getResolvedBeadsDir())
|
||||
|
||||
// Ensure target database has custom types configured
|
||||
// This is cached (sentinel file + in-memory) so repeated calls are fast
|
||||
if err := EnsureCustomTypes(targetDir); err != nil {
|
||||
return nil, fmt.Errorf("prepare target for agent bead %s: %w", id, err)
|
||||
}
|
||||
|
||||
description := FormatAgentDescription(title, fields)
|
||||
|
||||
args := []string{"create", "--json",
|
||||
@@ -160,8 +197,9 @@ func (b *Beads) CreateAgentBead(id, title string, fields *AgentFields) (*Issue,
|
||||
// Set the hook slot if specified (this is the authoritative storage)
|
||||
// This fixes the slot inconsistency bug where bead status is 'hooked' but
|
||||
// agent's hook slot is empty. See mi-619.
|
||||
// Must run from targetDir since that's where the agent bead was created
|
||||
if fields != nil && fields.HookBead != "" {
|
||||
if _, err := b.run("slot", "set", id, "hook", fields.HookBead); err != nil {
|
||||
if err := runSlotSet(targetDir, id, "hook", fields.HookBead); err != nil {
|
||||
// Non-fatal: warn but continue - description text has the backup
|
||||
fmt.Printf("Warning: could not set hook slot: %v\n", err)
|
||||
}
|
||||
@@ -195,6 +233,9 @@ func (b *Beads) CreateOrReopenAgentBead(id, title string, fields *AgentFields) (
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Resolve where this bead lives (for slot operations)
|
||||
targetDir := ResolveRoutingTarget(b.getTownRoot(), id, b.getResolvedBeadsDir())
|
||||
|
||||
// The bead already exists (should be closed from previous polecat lifecycle)
|
||||
// Reopen it and update its fields
|
||||
if _, reopenErr := b.run("reopen", id, "--reason=re-spawning agent"); reopenErr != nil {
|
||||
@@ -217,12 +258,14 @@ func (b *Beads) CreateOrReopenAgentBead(id, title string, fields *AgentFields) (
|
||||
// Note: role slot no longer set - role definitions are config-based
|
||||
|
||||
// Clear any existing hook slot (handles stale state from previous lifecycle)
|
||||
_, _ = b.run("slot", "clear", id, "hook")
|
||||
// Must run from targetDir since that's where the agent bead lives
|
||||
_ = runSlotClear(targetDir, id, "hook")
|
||||
|
||||
// Set the hook slot if specified
|
||||
// Must run from targetDir since that's where the agent bead lives
|
||||
if fields != nil && fields.HookBead != "" {
|
||||
if _, err := b.run("slot", "set", id, "hook", fields.HookBead); err != nil {
|
||||
// Non-fatal: warn but continue
|
||||
if err := runSlotSet(targetDir, id, "hook", fields.HookBead); err != nil {
|
||||
// Non-fatal: warn but continue - description text has the backup
|
||||
fmt.Printf("Warning: could not set hook slot: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
129
internal/beads/beads_types.go
Normal file
129
internal/beads/beads_types.go
Normal file
@@ -0,0 +1,129 @@
|
||||
// Package beads provides custom type management for agent beads.
|
||||
package beads
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/steveyegge/gastown/internal/constants"
|
||||
)
|
||||
|
||||
// typesSentinel is a marker file indicating custom types have been configured.
|
||||
// This persists across CLI invocations to avoid redundant bd config calls.
|
||||
const typesSentinel = ".gt-types-configured"
|
||||
|
||||
// ensuredDirs tracks which beads directories have been ensured this session.
|
||||
// This provides fast in-memory caching for multiple creates in the same CLI run.
|
||||
var (
|
||||
ensuredDirs = make(map[string]bool)
|
||||
ensuredMu sync.Mutex
|
||||
)
|
||||
|
||||
// FindTownRoot walks up from startDir to find the Gas Town root directory.
|
||||
// The town root is identified by the presence of mayor/town.json.
|
||||
// Returns empty string if not found (reached filesystem root).
|
||||
func FindTownRoot(startDir string) string {
|
||||
dir := startDir
|
||||
for {
|
||||
townFile := filepath.Join(dir, "mayor", "town.json")
|
||||
if _, err := os.Stat(townFile); err == nil {
|
||||
return dir
|
||||
}
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
return "" // Reached filesystem root
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
}
|
||||
|
||||
// ResolveRoutingTarget determines which beads directory a bead ID will route to.
|
||||
// It extracts the prefix from the bead ID and looks up the corresponding route.
|
||||
// Returns the resolved beads directory path, following any redirects.
|
||||
//
|
||||
// If townRoot is empty or prefix is not found, falls back to the provided fallbackDir.
|
||||
func ResolveRoutingTarget(townRoot, beadID, fallbackDir string) string {
|
||||
if townRoot == "" {
|
||||
return fallbackDir
|
||||
}
|
||||
|
||||
// Extract prefix from bead ID (e.g., "gt-gastown-polecat-Toast" -> "gt-")
|
||||
prefix := ExtractPrefix(beadID)
|
||||
if prefix == "" {
|
||||
return fallbackDir
|
||||
}
|
||||
|
||||
// Look up rig path for this prefix
|
||||
rigPath := GetRigPathForPrefix(townRoot, prefix)
|
||||
if rigPath == "" {
|
||||
return fallbackDir
|
||||
}
|
||||
|
||||
// Resolve redirects and get final beads directory
|
||||
beadsDir := ResolveBeadsDir(rigPath)
|
||||
if beadsDir == "" {
|
||||
return fallbackDir
|
||||
}
|
||||
|
||||
return beadsDir
|
||||
}
|
||||
|
||||
// EnsureCustomTypes ensures the target beads directory has custom types configured.
|
||||
// Uses a two-level caching strategy:
|
||||
// - In-memory cache for multiple creates in the same CLI invocation
|
||||
// - Sentinel file on disk for persistence across CLI invocations
|
||||
//
|
||||
// This function is thread-safe and idempotent.
|
||||
func EnsureCustomTypes(beadsDir string) error {
|
||||
if beadsDir == "" {
|
||||
return fmt.Errorf("empty beads directory")
|
||||
}
|
||||
|
||||
ensuredMu.Lock()
|
||||
defer ensuredMu.Unlock()
|
||||
|
||||
// Fast path: in-memory cache (same CLI invocation)
|
||||
if ensuredDirs[beadsDir] {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Fast path: sentinel file exists (previous CLI invocation)
|
||||
sentinelPath := filepath.Join(beadsDir, typesSentinel)
|
||||
if _, err := os.Stat(sentinelPath); err == nil {
|
||||
ensuredDirs[beadsDir] = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// Verify beads directory exists
|
||||
if _, err := os.Stat(beadsDir); os.IsNotExist(err) {
|
||||
return fmt.Errorf("beads directory does not exist: %s", beadsDir)
|
||||
}
|
||||
|
||||
// Configure custom types via bd CLI
|
||||
typesList := strings.Join(constants.BeadsCustomTypesList(), ",")
|
||||
cmd := exec.Command("bd", "config", "set", "types.custom", typesList)
|
||||
cmd.Dir = beadsDir
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("configure custom types in %s: %s: %w",
|
||||
beadsDir, strings.TrimSpace(string(output)), err)
|
||||
}
|
||||
|
||||
// Write sentinel file (best effort - don't fail if this fails)
|
||||
// The sentinel contains a version marker for future compatibility
|
||||
_ = os.WriteFile(sentinelPath, []byte("v1\n"), 0644)
|
||||
|
||||
ensuredDirs[beadsDir] = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResetEnsuredDirs clears the in-memory cache of ensured directories.
|
||||
// This is primarily useful for testing.
|
||||
func ResetEnsuredDirs() {
|
||||
ensuredMu.Lock()
|
||||
defer ensuredMu.Unlock()
|
||||
ensuredDirs = make(map[string]bool)
|
||||
}
|
||||
234
internal/beads/beads_types_test.go
Normal file
234
internal/beads/beads_types_test.go
Normal file
@@ -0,0 +1,234 @@
|
||||
package beads
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFindTownRoot(t *testing.T) {
|
||||
// Create a temporary town structure
|
||||
tmpDir := t.TempDir()
|
||||
mayorDir := filepath.Join(tmpDir, "mayor")
|
||||
if err := os.MkdirAll(mayorDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(mayorDir, "town.json"), []byte("{}"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create nested directories
|
||||
deepDir := filepath.Join(tmpDir, "rig1", "crew", "worker1")
|
||||
if err := os.MkdirAll(deepDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
startDir string
|
||||
expected string
|
||||
}{
|
||||
{"from town root", tmpDir, tmpDir},
|
||||
{"from mayor dir", mayorDir, tmpDir},
|
||||
{"from deep nested dir", deepDir, tmpDir},
|
||||
{"from non-town dir", t.TempDir(), ""},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result := FindTownRoot(tc.startDir)
|
||||
if result != tc.expected {
|
||||
t.Errorf("FindTownRoot(%q) = %q, want %q", tc.startDir, result, tc.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRoutingTarget(t *testing.T) {
|
||||
// Create a temporary town with routes
|
||||
tmpDir := t.TempDir()
|
||||
beadsDir := filepath.Join(tmpDir, ".beads")
|
||||
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create mayor/town.json for FindTownRoot
|
||||
mayorDir := filepath.Join(tmpDir, "mayor")
|
||||
if err := os.MkdirAll(mayorDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(mayorDir, "town.json"), []byte("{}"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create routes.jsonl
|
||||
routesContent := `{"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)
|
||||
}
|
||||
|
||||
// Create the rig beads directory
|
||||
rigBeadsDir := filepath.Join(tmpDir, "gastown", "mayor", "rig", ".beads")
|
||||
if err := os.MkdirAll(rigBeadsDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
fallback := "/fallback/.beads"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
townRoot string
|
||||
beadID string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "rig-level bead routes to rig",
|
||||
townRoot: tmpDir,
|
||||
beadID: "gt-gastown-polecat-Toast",
|
||||
expected: rigBeadsDir,
|
||||
},
|
||||
{
|
||||
name: "town-level bead routes to town",
|
||||
townRoot: tmpDir,
|
||||
beadID: "hq-mayor",
|
||||
expected: beadsDir,
|
||||
},
|
||||
{
|
||||
name: "unknown prefix falls back",
|
||||
townRoot: tmpDir,
|
||||
beadID: "xx-unknown",
|
||||
expected: fallback,
|
||||
},
|
||||
{
|
||||
name: "empty townRoot falls back",
|
||||
townRoot: "",
|
||||
beadID: "gt-gastown-polecat-Toast",
|
||||
expected: fallback,
|
||||
},
|
||||
{
|
||||
name: "no prefix falls back",
|
||||
townRoot: tmpDir,
|
||||
beadID: "noprefixid",
|
||||
expected: fallback,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result := ResolveRoutingTarget(tc.townRoot, tc.beadID, fallback)
|
||||
if result != tc.expected {
|
||||
t.Errorf("ResolveRoutingTarget(%q, %q, %q) = %q, want %q",
|
||||
tc.townRoot, tc.beadID, fallback, result, tc.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureCustomTypes(t *testing.T) {
|
||||
// Reset the in-memory cache before testing
|
||||
ResetEnsuredDirs()
|
||||
|
||||
t.Run("empty beads dir returns error", func(t *testing.T) {
|
||||
err := EnsureCustomTypes("")
|
||||
if err == nil {
|
||||
t.Error("expected error for empty beads dir")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-existent beads dir returns error", func(t *testing.T) {
|
||||
err := EnsureCustomTypes("/nonexistent/path/.beads")
|
||||
if err == nil {
|
||||
t.Error("expected error for non-existent beads dir")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("sentinel file triggers cache hit", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
beadsDir := filepath.Join(tmpDir, ".beads")
|
||||
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create sentinel file
|
||||
sentinelPath := filepath.Join(beadsDir, typesSentinel)
|
||||
if err := os.WriteFile(sentinelPath, []byte("v1\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Reset cache to ensure we're testing sentinel detection
|
||||
ResetEnsuredDirs()
|
||||
|
||||
// This should succeed without running bd (sentinel exists)
|
||||
err := EnsureCustomTypes(beadsDir)
|
||||
if err != nil {
|
||||
t.Errorf("expected success with sentinel file, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("in-memory cache prevents repeated calls", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
beadsDir := filepath.Join(tmpDir, ".beads")
|
||||
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create sentinel to avoid bd call
|
||||
sentinelPath := filepath.Join(beadsDir, typesSentinel)
|
||||
if err := os.WriteFile(sentinelPath, []byte("v1\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ResetEnsuredDirs()
|
||||
|
||||
// First call
|
||||
if err := EnsureCustomTypes(beadsDir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Remove sentinel - second call should still succeed due to in-memory cache
|
||||
os.Remove(sentinelPath)
|
||||
|
||||
if err := EnsureCustomTypes(beadsDir); err != nil {
|
||||
t.Errorf("expected cache hit, got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBeads_getTownRoot(t *testing.T) {
|
||||
// Create a temporary town
|
||||
tmpDir := t.TempDir()
|
||||
mayorDir := filepath.Join(tmpDir, "mayor")
|
||||
if err := os.MkdirAll(mayorDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(mayorDir, "town.json"), []byte("{}"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create nested directory
|
||||
rigDir := filepath.Join(tmpDir, "myrig", "mayor", "rig")
|
||||
if err := os.MkdirAll(rigDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
b := New(rigDir)
|
||||
|
||||
// First call should find town root
|
||||
root1 := b.getTownRoot()
|
||||
if root1 != tmpDir {
|
||||
t.Errorf("first getTownRoot() = %q, want %q", root1, tmpDir)
|
||||
}
|
||||
|
||||
// Second call should return cached value
|
||||
root2 := b.getTownRoot()
|
||||
if root2 != root1 {
|
||||
t.Errorf("second getTownRoot() = %q, want cached %q", root2, root1)
|
||||
}
|
||||
|
||||
// Verify searchedRoot flag is set
|
||||
if !b.searchedRoot {
|
||||
t.Error("expected searchedRoot to be true after getTownRoot()")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user