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:
gastown/crew/dennis
2026-01-21 10:44:37 -08:00
committed by beads/crew/emma
parent b2b9cbc836
commit fa1f812ce9
4 changed files with 435 additions and 4 deletions

View File

@@ -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 {

View File

@@ -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)
}
}

View 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)
}

View 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()")
}
}